feat: add Docker deployment and Microsoft OAuth support
This commit is contained in:
165
mengpost-backend/services/microsoft_oauth.go
Normal file
165
mengpost-backend/services/microsoft_oauth.go
Normal file
@@ -0,0 +1,165 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user