166 lines
5.1 KiB
Go
166 lines
5.1 KiB
Go
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
|
||
}
|