package services import ( "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "mengpost-backend/config" ) var msTokenURL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token" var msAuthURL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize" // MicrosoftOAuthScopes IMAP + SMTP + 刷新令牌. var MicrosoftOAuthScopes = []string{ "openid", "email", "offline_access", "https://outlook.office.com/IMAP.AccessAsUser.All", "https://outlook.office.com/SMTP.Send", } // MicrosoftAuthCodeURL 生成跳转微软登录的 URL. func MicrosoftAuthCodeURL(cfg *config.Config, state string) string { q := url.Values{} q.Set("client_id", cfg.MSClientID) q.Set("response_type", "code") q.Set("redirect_uri", cfg.MSRedirectURL) q.Set("response_mode", "query") q.Set("scope", strings.Join(MicrosoftOAuthScopes, " ")) q.Set("state", state) return msAuthURL + "?" + q.Encode() } type msTokenResp struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` IDToken string `json:"id_token"` ExpiresIn int `json:"expires_in"` Error string `json:"error"` Description string `json:"error_description"` } // ExchangeMicrosoftCode 用授权码换令牌并解析邮箱(id_token). func ExchangeMicrosoftCode(ctx context.Context, cfg *config.Config, code string) (email, refresh, access string, accessExpiry time.Time, err error) { form := url.Values{} form.Set("client_id", cfg.MSClientID) if cfg.MSClientSecret != "" { form.Set("client_secret", cfg.MSClientSecret) } form.Set("code", code) form.Set("redirect_uri", cfg.MSRedirectURL) form.Set("grant_type", "authorization_code") req, err := http.NewRequestWithContext(ctx, http.MethodPost, msTokenURL, strings.NewReader(form.Encode())) if err != nil { return "", "", "", time.Time{}, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := http.DefaultClient.Do(req) if err != nil { return "", "", "", time.Time{}, err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var tr msTokenResp if err := json.Unmarshal(body, &tr); err != nil { return "", "", "", time.Time{}, fmt.Errorf("token 响应解析失败: %w", err) } if tr.Error != "" { return "", "", "", time.Time{}, fmt.Errorf("微软 token错误: %s %s", tr.Error, tr.Description) } if tr.RefreshToken == "" { return "", "", "", time.Time{}, fmt.Errorf("未返回 refresh_token,请确认 scope 含 offline_access") } email, err = emailFromIDToken(tr.IDToken) if err != nil || email == "" { return "", "", "", time.Time{}, fmt.Errorf("无法从 id_token 解析邮箱: %w", err) } exp := time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second) return strings.ToLower(strings.TrimSpace(email)), tr.RefreshToken, tr.AccessToken, exp, nil } func emailFromIDToken(idt string) (string, error) { if idt == "" { return "", fmt.Errorf("无 id_token") } parts := strings.Split(idt, ".") if len(parts) < 2 { return "", fmt.Errorf("id_token 格式异常") } payload, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return "", err } var claims struct { Email string `json:"email"` PreferredUsername string `json:"preferred_username"` UPN string `json:"upn"` } if err := json.Unmarshal(payload, &claims); err != nil { return "", err } if claims.Email != "" { return claims.Email, nil } if claims.PreferredUsername != "" { return claims.PreferredUsername, nil } if claims.UPN != "" { return claims.UPN, nil } return "", fmt.Errorf("claims 中无邮箱字段") } // MicrosoftAccessToken 用 refresh_token 换新 access_token(及可能的新 refresh). type MicrosoftAccessToken struct { AccessToken string RefreshToken string // 若微软返回新的则替换存库 Expiry time.Time } func RefreshMicrosoftAccess(ctx context.Context, cfg *config.Config, refresh string) (*MicrosoftAccessToken, error) { form := url.Values{} form.Set("client_id", cfg.MSClientID) if cfg.MSClientSecret != "" { form.Set("client_secret", cfg.MSClientSecret) } form.Set("refresh_token", refresh) form.Set("grant_type", "refresh_token") form.Set("scope", strings.Join(MicrosoftOAuthScopes, " ")) req, err := http.NewRequestWithContext(ctx, http.MethodPost, msTokenURL, strings.NewReader(form.Encode())) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var tr msTokenResp if err := json.Unmarshal(body, &tr); err != nil { return nil, err } if tr.Error != "" { return nil, fmt.Errorf("%s: %s", tr.Error, tr.Description) } out := &MicrosoftAccessToken{ AccessToken: tr.AccessToken, Expiry: time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second), } if tr.RefreshToken != "" { out.RefreshToken = tr.RefreshToken } return out, nil }