Files
mengpost/mengpost-backend/services/microsoft_oauth.go

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