375 lines
11 KiB
Go
375 lines
11 KiB
Go
package login
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/base64"
|
||
"fmt"
|
||
"likedream-goframe/internal/application/cookiejar"
|
||
"likedream-goframe/internal/application/cpdaily/cryptoutil"
|
||
"likedream-goframe/internal/application/cpdaily/school"
|
||
"likedream-goframe/internal/application/httpclient"
|
||
"likedream-goframe/internal/common/cst"
|
||
"likedream-goframe/internal/common/errors"
|
||
"likedream-goframe/internal/framework/logger"
|
||
"likedream-goframe/internal/utils"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
|
||
"github.com/bytedance/sonic"
|
||
"github.com/samber/oops"
|
||
"go.uber.org/zap"
|
||
"resty.dev/v3"
|
||
)
|
||
|
||
type cpdailySmsCommonResponse[T any] struct {
|
||
ErrCode int `json:"errCode"`
|
||
ErrMsg string `json:"errMsg"`
|
||
Data T `json:"data"`
|
||
}
|
||
|
||
func (c *cpdailySmsCommonResponse[T]) EnsureSuccess() {
|
||
if c.ErrCode != 0 {
|
||
utils.OkOrPanic(errors.NewBusinessError(503, c.ErrMsg))
|
||
}
|
||
}
|
||
|
||
type CpdailySmsSendSmsData struct {
|
||
Countdown int `json:"countdown"`
|
||
TipMsg string `json:"tipMsg"`
|
||
Status int `json:"status"`
|
||
CookieJar *cookiejar.PersistCookieJar `json:"-"`
|
||
}
|
||
|
||
type CpdailySmsLoginByCodeData struct {
|
||
AuthID string `json:"authId"`
|
||
AuthStatus string `json:"authStatus"`
|
||
CompleteUserInfoStatus string `json:"completeUserInfoStatus"`
|
||
DeviceExceptionMsg string `json:"deviceExceptionMsg"`
|
||
DeviceStatus string `json:"deviceStatus"`
|
||
FirstLogin bool `json:"firstLogin"`
|
||
Mobile string `json:"mobile"`
|
||
MsgTip string `json:"msgTip"`
|
||
Name string `json:"name"`
|
||
OpenID string `json:"openId"`
|
||
PersonID string `json:"personId"`
|
||
SessionToken string `json:"sessionToken"`
|
||
Status string `json:"status"`
|
||
TenantID string `json:"tenantId"`
|
||
TenantName string `json:"tenantName"`
|
||
Tgc string `json:"tgc"`
|
||
TgcStatus string `json:"tgcStatus"`
|
||
UserID string `json:"userId"`
|
||
UserType string `json:"userType"`
|
||
}
|
||
|
||
func NewSms(deviceID string) *SmsLoginHandler {
|
||
return &SmsLoginHandler{
|
||
deviceID: deviceID,
|
||
}
|
||
}
|
||
|
||
func NewSmsWithoutDeviceID() *SmsLoginHandler {
|
||
return &SmsLoginHandler{
|
||
deviceID: "",
|
||
}
|
||
}
|
||
|
||
type SmsLoginHandler struct {
|
||
deviceID string
|
||
client *resty.Client
|
||
}
|
||
|
||
func (s *SmsLoginHandler) SendSms(ctx context.Context, phone string) *CpdailySmsSendSmsData {
|
||
jar := cookiejar.NewPersistCookieJar()
|
||
|
||
phoneEncrypted := base64.StdEncoding.EncodeToString(cryptoutil.AesEncrypt([]byte(phone)))
|
||
s.client = httpclient.NewCpdailyClient()
|
||
req := s.client.
|
||
SetCookieJar(jar).
|
||
R().
|
||
SetContext(utils.NewCpdailySmsContextBuilder().
|
||
WithDeviceID(s.deviceID).
|
||
Build(ctx)).
|
||
SetBody(map[string]string{
|
||
"a": phoneEncrypted,
|
||
"b": cryptoutil.EncryptVersion,
|
||
})
|
||
|
||
setSmsRequestHeaders(ctx, req)
|
||
|
||
resp, e := req.
|
||
Post("https://mobile.campushoy.com/app/auth/authentication/mobile/messageCode/v-8222")
|
||
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
|
||
|
||
respJson := utils.ReadJson[cpdailySmsCommonResponse[CpdailySmsSendSmsData]](resp.Body)
|
||
respJson.EnsureSuccess()
|
||
|
||
respJson.Data.CookieJar = jar
|
||
|
||
return &respJson.Data
|
||
}
|
||
|
||
func (s *SmsLoginHandler) LoginByCode(ctx context.Context, phone, code string, jar http.CookieJar) *CpdailySmsLoginByCodeData {
|
||
payload := map[string]any{
|
||
"d": code,
|
||
"e": "CPDAILY",
|
||
"c": phone,
|
||
"f": "86",
|
||
}
|
||
|
||
payloadString, e := sonic.Marshal(payload)
|
||
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
|
||
|
||
encryptedPayload := cryptoutil.AesEncrypt(payloadString)
|
||
encodedPayload := base64.StdEncoding.EncodeToString(encryptedPayload)
|
||
|
||
payload = map[string]any{
|
||
"a": encodedPayload,
|
||
"b": cryptoutil.EncryptVersion,
|
||
}
|
||
s.client = httpclient.NewCpdailyClient()
|
||
s.client.SetCookieJar(jar)
|
||
req := s.client.R().
|
||
SetBody(payload).
|
||
SetContext(utils.NewCpdailySmsContextBuilder().
|
||
WithDeviceID(s.deviceID).
|
||
Build(ctx))
|
||
setSmsRequestHeaders(ctx, req)
|
||
|
||
resp, e := req.
|
||
Post("https://mobile.campushoy.com/app/auth/authentication/mobileLogin/v-8222")
|
||
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
|
||
|
||
respJson := utils.ReadJson[cpdailySmsCommonResponse[string]](resp.Body)
|
||
respJson.EnsureSuccess()
|
||
|
||
data := decodeData[CpdailySmsLoginByCodeData](respJson.Data)
|
||
|
||
return &data
|
||
}
|
||
|
||
func (s *SmsLoginHandler) SetLoginCookie(ctx context.Context, jar http.CookieJar, loginData *CpdailySmsLoginByCodeData) {
|
||
detail := school.GetSchoolDetail(ctx, loginData.TenantID)
|
||
|
||
getCookieUrl := fmt.Sprintf("%s%s", detail.WecloudUrl, "/wec-im-group/group/imUserToken/getImUserToken")
|
||
|
||
cookieMap := map[string]any{
|
||
"AMP2": []map[string]any{
|
||
{
|
||
"path": "/",
|
||
"value": loginData.SessionToken,
|
||
"domain": "",
|
||
"name": "sessionToken",
|
||
},
|
||
},
|
||
"AMP1": []map[string]any{
|
||
{
|
||
"path": "/",
|
||
"value": loginData.SessionToken,
|
||
"domain": "",
|
||
"name": "sessionToken",
|
||
},
|
||
},
|
||
}
|
||
|
||
ampTokenString, e := sonic.Marshal(cookieMap)
|
||
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
|
||
|
||
ampTokenEncrypted := cryptoutil.DesEncrypt(ampTokenString, cryptoutil.Normal)
|
||
ampToken := base64.StdEncoding.EncodeToString(ampTokenEncrypted)
|
||
|
||
// 设置cookie到jar: clientType=cpdaily_student; tenantId=ahnu; standAlone=0; sessionToken=5e55bfa7-d17e-4f5a-a06d-99a649855521;
|
||
targetSchoolUrl, e := url.Parse(detail.WecloudUrl)
|
||
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
|
||
targetApiUrl, e := url.Parse("https://api.campushoy.com")
|
||
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
|
||
|
||
cookieNameValues := map[string]string{
|
||
"clientType": "cpdaily_student",
|
||
"tenantId": detail.Id,
|
||
"standAlone": "0",
|
||
"sessionToken": loginData.SessionToken,
|
||
}
|
||
|
||
for name, value := range cookieNameValues {
|
||
cookie := &http.Cookie{
|
||
Name: name,
|
||
Value: value,
|
||
}
|
||
|
||
jar.SetCookies(targetSchoolUrl, []*http.Cookie{cookie})
|
||
jar.SetCookies(targetApiUrl, []*http.Cookie{cookie})
|
||
}
|
||
|
||
s.client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(50))
|
||
req := s.client.R().
|
||
SetHeader("AmpCookies", ampToken).
|
||
SetContext(ctx)
|
||
|
||
ctx = utils.NewCpdailySmsContextBuilder().
|
||
WithDeviceID(s.deviceID).
|
||
WithTenantID(detail.Id).
|
||
WithSessionToken(loginData.SessionToken).
|
||
Build(ctx)
|
||
|
||
setSmsRequestHeaders(ctx, req)
|
||
|
||
resp, e := req.
|
||
Get(getCookieUrl)
|
||
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
|
||
|
||
respJson := utils.ReadJson[cpdailySmsCommonResponse[any]](resp.Body)
|
||
respJson.EnsureSuccess()
|
||
}
|
||
|
||
func decodeData[T any](encodedData string) T {
|
||
decodedBytes, e := base64.StdEncoding.DecodeString(encodedData)
|
||
utils.OkOrPanic(e)
|
||
|
||
decryptedBytes := cryptoutil.AesDecrypt(decodedBytes)
|
||
|
||
result := utils.ReadJson[T](bytes.NewReader(decryptedBytes))
|
||
|
||
return result
|
||
}
|
||
|
||
func setSmsRequestHeaders(ctx context.Context, request *resty.Request) {
|
||
sessionToken, ok := ctx.Value(cst.CtxCpdailySessionToken).(string)
|
||
if ok {
|
||
encryptedSessionToken := cryptoutil.DesEncrypt([]byte(sessionToken), cryptoutil.Normal)
|
||
encodedSessionToken := base64.StdEncoding.EncodeToString(encryptedSessionToken)
|
||
request.SetHeader("SessionToken", encodedSessionToken)
|
||
request.SetHeader("sessionTokenKey", encodedSessionToken)
|
||
} else {
|
||
request.SetHeader("SessionToken", sessionToken)
|
||
request.SetHeader("sessionTokenKey", sessionToken)
|
||
}
|
||
|
||
deviceId, _ := ctx.Value(cst.CtxCpdailyDeviceID).(string)
|
||
|
||
cpdailyInfoDetail := map[string]any{
|
||
"deviceId": deviceId,
|
||
"systemName": "android",
|
||
"userId": "",
|
||
"appVersion": "9.8.8",
|
||
"model": "Unknown",
|
||
"lon": "0",
|
||
"lat": "0",
|
||
"cpdailyVersion": "9.8.8",
|
||
"systemVersion": "11",
|
||
}
|
||
|
||
cpdailyInfoStr, e := sonic.Marshal(cpdailyInfoDetail)
|
||
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
|
||
|
||
encryptedCpdailyInfo := cryptoutil.DesEncrypt(cpdailyInfoStr, cryptoutil.Normal)
|
||
encodedCpdailyInfo := base64.StdEncoding.EncodeToString(encryptedCpdailyInfo)
|
||
|
||
request.SetHeaders(map[string]string{
|
||
"clientType": "cpdaily_student",
|
||
"deviceType": "1",
|
||
"CpdailyClientType": "CPDAILY",
|
||
"CpdailyStandAlone": "0",
|
||
"User-Agent": "Mozilla/5.0 (Linux; Android 11; Redmi Note 8 Pro Build/RP1A.230805.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/83.0.4103.106 Mobile Safari/537.36 cpdaily/9.8.8 wisedu/9.8.8",
|
||
"CpdailyInfo": encodedCpdailyInfo,
|
||
})
|
||
}
|
||
|
||
func (s *SmsLoginHandler) GetCookieString() string {
|
||
cookieJar := s.client.CookieJar()
|
||
serializableJar := cookieJar.(*cookiejar.PersistCookieJar)
|
||
return serializableJar.Serialize()
|
||
}
|
||
|
||
// RefreshByTgc 使用保存的完整 cookie(包含 CASTGC 等长期凭证)重新去学校换取 CAS cookie。
|
||
// tgc 参数是 SetLoginCookie 之后序列化的完整 cookie jar,包含 CASTGC(有效期10年)、
|
||
// sessionToken 等认证信息。流程:先用 sessionToken 调用 getImUserToken 换取新的 CAS cookie,
|
||
// 再激活 session。
|
||
func (s *SmsLoginHandler) RefreshByTgc(ctx context.Context, tgc string, schoolId string) (cookie string, newTgc string) {
|
||
jar := cookiejar.NewPersistCookieJar()
|
||
jar.Deserialize(tgc)
|
||
// 合并所有 host 的 cookie 到 global,消除因多 host 存储导致的重复 cookie
|
||
jar.RemoveCookieInfo()
|
||
s.client = httpclient.NewCpdailyClient()
|
||
s.client.SetCookieJar(jar)
|
||
|
||
detail := school.GetSchoolDetail(ctx, schoolId)
|
||
|
||
logger.Logger.Info("[TGC] RefreshByTgc 开始用TGC换取CAS cookie",
|
||
zap.String("schoolId", schoolId),
|
||
zap.String("wecloudUrl", detail.WecloudUrl),
|
||
zap.Int("tgcLen", len(tgc)),
|
||
)
|
||
|
||
// 从 cookie jar 中提取 sessionToken,用于换取新的 CAS cookie
|
||
schoolUrl, _ := url.Parse(detail.WecloudUrl)
|
||
sessionToken := ""
|
||
for _, c := range jar.Cookies(schoolUrl) {
|
||
if c.Name == "sessionToken" {
|
||
sessionToken = c.Value
|
||
break
|
||
}
|
||
}
|
||
|
||
if sessionToken != "" {
|
||
// 换取新的 CAS cookie(与 SetLoginCookie 相同的流程)
|
||
getCookieUrl := fmt.Sprintf("%s%s", detail.WecloudUrl, "/wec-im-group/group/imUserToken/getImUserToken")
|
||
|
||
ampCookieMap := map[string]any{
|
||
"AMP2": []map[string]any{
|
||
{"path": "/", "value": sessionToken, "domain": "", "name": "sessionToken"},
|
||
},
|
||
"AMP1": []map[string]any{
|
||
{"path": "/", "value": sessionToken, "domain": "", "name": "sessionToken"},
|
||
},
|
||
}
|
||
|
||
ampTokenString, e := sonic.Marshal(ampCookieMap)
|
||
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
|
||
|
||
ampTokenEncrypted := cryptoutil.DesEncrypt(ampTokenString, cryptoutil.Normal)
|
||
ampToken := base64.StdEncoding.EncodeToString(ampTokenEncrypted)
|
||
|
||
ctx = utils.NewCpdailySmsContextBuilder().
|
||
WithDeviceID(s.deviceID).
|
||
WithTenantID(detail.Id).
|
||
WithSessionToken(sessionToken).
|
||
Build(ctx)
|
||
|
||
// 使用不复制 Cookie 头的重定向策略,避免旧 Cookie 覆盖 jar 中更新的值
|
||
s.client.SetRedirectPolicy(resty.RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error {
|
||
if len(via) >= 50 {
|
||
return fmt.Errorf("resty: stopped after 50 redirects")
|
||
}
|
||
// 只复制非 Cookie 头,让 jar 自动管理 Cookie
|
||
for key, val := range via[0].Header {
|
||
if !strings.EqualFold(key, "Cookie") {
|
||
req.Header[key] = val
|
||
}
|
||
}
|
||
return nil
|
||
}))
|
||
req := s.client.R().
|
||
SetHeader("AmpCookies", ampToken).
|
||
SetContext(ctx)
|
||
|
||
setSmsRequestHeaders(ctx, req)
|
||
|
||
resp, e := req.Get(getCookieUrl)
|
||
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
|
||
|
||
respJson := utils.ReadJson[cpdailySmsCommonResponse[any]](resp.Body)
|
||
respJson.EnsureSuccess()
|
||
|
||
logger.Logger.Info("[TGC] RefreshByTgc 换取CAS cookie完成")
|
||
} else {
|
||
logger.Logger.Warn("[TGC] RefreshByTgc 未找到sessionToken,跳过换取CAS cookie步骤")
|
||
}
|
||
|
||
newTgc = tgc // TGC 保持不变(CASTGC 有效期10年)
|
||
cookie = s.GetCookieString()
|
||
return
|
||
}
|