Files
cpdaily_xiaozhushou/backed/internal/service/cpdaily/login_iap.go
T
becomer 2e2c161770 feat: init cpdaily-xiaozhushou with blue-green deploy
- backed/: Go Fiber backend with cpdaily integration
- frondend/: Vue 3 + Vite frontend
- Dockerfile: multi-stage build (Node → Go → Alpine)
- deploy/docker-compose.yml: production compose file
- .woodpecker/build.yml: CI/CD with blue-green zero-downtime deploy
- /health endpoint for deployment health checks
- Port 8884/8885 blue-green swap via 1Panel OpenResty
2026-07-03 15:37:55 +08:00

266 lines
6.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package cpdaily
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"cpdaily-new/internal/config"
"github.com/go-resty/resty/v2"
)
// ========== IAP 登录 ==========
func iapLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
sc := newSessionClient()
loginUrl := getIapValidLoginUrl(school)
// 1. 获取 LT (Login Ticket)
lt, err := getIapLT(ctx, loginUrl, sc)
if err != nil {
return nil, fmt.Errorf("获取LT失败: %w", err)
}
// 2. 获取真实 _lt
realLT, err := getIapRealLT(ctx, loginUrl, lt, sc.Client)
if err != nil {
return nil, fmt.Errorf("获取真实LT失败: %w", err)
}
// 3. RSA 加密密码
rsa, err := NewRSAKeyPairFromPublicKey(IapRsaPubKey)
if err != nil {
return nil, fmt.Errorf("RSA密钥解析失败: %w", err)
}
encPwd, err := rsa.Encrypt(password)
if err != nil {
return nil, fmt.Errorf("RSA加密失败: %w", err)
}
// 4. 检测验证码
captchaCode := ""
if needIapCaptcha(ctx, loginUrl, realLT, sc.Client) {
captchaCode = resolveIapCaptcha(ctx, loginUrl, realLT, sc.Client)
}
// 5. 提交登录
submitData := map[string]string{
"lt": realLT,
"rememberMe": "true",
"dllt": "",
"mobile": "",
"captcha": captchaCode,
"username": username,
"password": "{rsa}" + encPwd,
}
submitUrls := []string{
loginUrl + "/iap/doLogin",
loginUrl + "/doLogin",
}
var loginResp *resty.Response
var lastErr error
for _, submitUrl := range submitUrls {
loginResp, lastErr = sc.R().
SetFormData(submitData).
SetContext(ctx).
Post(submitUrl)
if lastErr == nil {
break
}
}
if lastErr != nil {
return nil, fmt.Errorf("提交IAP登录失败: %w", lastErr)
}
defer loginResp.RawBody().Close()
// 6. 处理响应
switch loginResp.StatusCode() {
case 302:
return handleIapLoginSuccess(ctx, loginResp, school, sc)
case 200:
body, _ := io.ReadAll(loginResp.RawBody())
var result struct {
ResultCode int `json:"resultCode"`
ResultMsg string `json:"resultMsg"`
}
if err := json.Unmarshal(body, &result); err == nil {
switch result.ResultCode {
case 1:
return sc.jar.AllCookies(), nil
case -4:
return nil, fmt.Errorf("验证码错误")
case -3:
return nil, fmt.Errorf("账号或密码错误")
}
if result.ResultMsg != "" {
return nil, fmt.Errorf("%s", result.ResultMsg)
}
}
return nil, fmt.Errorf("IAP登录失败,未知错误")
default:
return nil, fmt.Errorf("IAP登录返回异常状态码: %d", loginResp.StatusCode())
}
}
func getIapValidLoginUrl(school *SchoolDetailInfo) string {
if school.WecloudUrl != "" {
return school.WecloudUrl
}
return school.AmpUrl
}
func getIapLT(ctx context.Context, baseUrl string, sc *sessionClient) (string, error) {
sc.SetRedirectPolicy(resty.NoRedirectPolicy())
resp, err := sc.R().SetContext(ctx).Get(baseUrl + "/portal/login")
if err != nil {
resp, err = sc.R().SetContext(ctx).Get(baseUrl + "/iap/login/mobile.html")
}
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
if err != nil {
return "", err
}
defer resp.RawBody().Close()
if resp.StatusCode() == 302 {
loc, _ := resp.RawResponse.Location()
if loc != nil {
lt := loc.Query().Get("lt")
if lt != "" {
return lt, nil
}
}
}
body, _ := io.ReadAll(resp.RawBody())
return extractLTFromPage(string(body)), nil
}
func getIapRealLT(ctx context.Context, baseUrl, lt string, client *resty.Client) (string, error) {
resp, err := client.R().
SetFormData(map[string]string{"lt": lt}).
SetContext(ctx).
Post(baseUrl + "/iap/security/lt")
if err != nil {
return "", err
}
defer resp.RawBody().Close()
body, _ := io.ReadAll(resp.RawBody())
var result struct {
Result struct {
LT string `json:"_lt"`
} `json:"result"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("解析LT响应失败: %w", err)
}
if result.Result.LT == "" {
return "", fmt.Errorf("获取真实LT为空")
}
return result.Result.LT, nil
}
func needIapCaptcha(ctx context.Context, baseUrl, lt string, client *resty.Client) bool {
resp, err := client.R().
SetFormData(map[string]string{"ltId": lt}).
SetContext(ctx).
Post(baseUrl + "/iap/checkNeedCaptcha")
if err != nil {
return false
}
defer resp.RawBody().Close()
body, _ := io.ReadAll(resp.RawBody())
return strings.Contains(string(body), `"needCaptcha":true`)
}
func resolveIapCaptcha(ctx context.Context, baseUrl, lt string, client *resty.Client) string {
captchaUrl := fmt.Sprintf("%s/iap/generateCaptcha?ltId=%s", baseUrl, lt)
imgResp, err := client.R().SetContext(ctx).Get(captchaUrl)
if err != nil {
return ""
}
defer imgResp.RawBody().Close()
imgData, _ := io.ReadAll(imgResp.RawBody())
return captchaOCR(imgData)
}
func handleIapLoginSuccess(ctx context.Context, resp *resty.Response, school *SchoolDetailInfo, sc *sessionClient) ([]*http.Cookie, error) {
jumpUrl, err := resp.RawResponse.Location()
if err != nil {
return nil, fmt.Errorf("获取IAP重定向地址失败: %w", err)
}
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
verifyResp, err := sc.R().SetContext(ctx).Get(jumpUrl.String())
if err != nil {
return nil, fmt.Errorf("IAP跟随重定向失败: %w", err)
}
defer verifyResp.RawBody().Close()
activateSession(ctx, school.WecloudUrl, sc.Client)
return sc.jar.AllCookies(), nil
}
func extractLTFromPage(page string) string {
idx := strings.Index(page, `"lt":"`)
if idx > 0 {
end := strings.Index(page[idx+5:], `"`)
if end > 0 {
return page[idx+5 : idx+5+end]
}
}
return ""
}
func getHost(rawUrl string) string {
u, err := url.Parse(rawUrl)
if err != nil {
return rawUrl
}
return u.Host
}
// ========== OCR 验证码识别 ==========
func captchaOCR(imgData []byte) string {
// 创建专用客户端(不继承 DoNotParseResponse)
cfg := config.Global.Cpdaily
resp, err := resty.New().
SetTimeout(15 * time.Second).
R().
SetHeader("X-Api-Key", cfg.CaptchaApiKey).
SetFileReader("file", "captcha.jpg", bytes.NewReader(imgData)).
Post(cfg.CaptchaBaseUrl + "/ocr")
if err != nil {
slog.Warn("OCR请求失败", "err", err)
return ""
}
var result struct {
Code int `json:"code"`
Data string `json:"data"`
}
bodyStr := resp.String()
if err := json.Unmarshal([]byte(bodyStr), &result); err != nil {
slog.Warn("OCR响应解析失败", "err", err, "body", bodyStr)
return ""
}
if result.Code != 200 {
slog.Warn("OCR识别失败", "code", result.Code, "data", result.Data)
return ""
}
slog.Info("OCR识别结果", "data", result.Data)
return result.Data
}