package handlers import ( "context" "crypto/rand" "database/sql" "encoding/hex" "errors" "net/http" "net/url" "strings" "time" "github.com/gin-gonic/gin" "mengpost-backend/config" "mengpost-backend/models" "mengpost-backend/services" ) const ( msSMTPHost = "smtp.office365.com" msSMTPPort = 587 msIMAPHost = "outlook.office365.com" msIMAPPort = 993 oauthPath = "/admin/accounts" tokenTimeout = 90 * time.Second ) func randomOAuthState() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } // MicrosoftOAuthEnabled GET /api/oauth/microsoft/enabled func MicrosoftOAuthEnabled(cfg *config.Config) gin.HandlerFunc { return func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"enabled": cfg.MicrosoftOAuthEnabled()}) } } // MicrosoftOAuthStart GET /api/oauth/microsoft/start func MicrosoftOAuthStart(cfg *config.Config) gin.HandlerFunc { return func(c *gin.Context) { if !cfg.MicrosoftOAuthEnabled() { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "未配置微软 OAuth(MP_MS_CLIENT_ID)"}) return } state, err := randomOAuthState() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if err := models.InsertOAuthPendingState(state); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } authURL := services.MicrosoftAuthCodeURL(cfg, state) c.JSON(http.StatusOK, gin.H{"url": authURL, "state": state}) } } // MicrosoftOAuthCallback GET /api/oauth/microsoft/callback(微软公网回调,无 token) func MicrosoftOAuthCallback(cfg *config.Config) gin.HandlerFunc { frontendAccounts := strings.TrimSuffix(cfg.FrontendURL, "/") + oauthPath return func(c *gin.Context) { q := c.Request.URL.Query() if errMsg := q.Get("error"); errMsg != "" { desc := q.Get("error_description") msg := errMsg if desc != "" { msg = errMsg + ": " + desc } c.Redirect(http.StatusFound, frontendAccounts+"?oauth_err="+url.QueryEscape(msg)) return } state := q.Get("state") code := q.Get("code") if state == "" || code == "" { c.Redirect(http.StatusFound, frontendAccounts+"?oauth_err="+url.QueryEscape("缺少 code 或 state")) return } ctx, cancel := context.WithTimeout(c.Request.Context(), tokenTimeout) defer cancel() email, refresh, _, _, err := services.ExchangeMicrosoftCode(ctx, cfg, code) if err != nil { c.Redirect(http.StatusFound, frontendAccounts+"?oauth_err="+url.QueryEscape(err.Error())) return } if err := models.UpdateOAuthPendingTokens(state, refresh, email); err != nil { c.Redirect(http.StatusFound, frontendAccounts+"?oauth_err="+url.QueryEscape(err.Error())) return } c.Redirect(http.StatusFound, frontendAccounts+"?oauth_ms="+url.QueryEscape(state)) } } type microsoftFinishBody struct { State string `json:"state"` } // MicrosoftOAuthFinish POST /api/oauth/microsoft/finish func MicrosoftOAuthFinish() gin.HandlerFunc { return func(c *gin.Context) { var body microsoftFinishBody if err := c.ShouldBindJSON(&body); err != nil || body.State == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "需要 JSON: {\"state\":\"...\"}"}) return } refresh, email, err := models.TakeOAuthPending(body.State) if err != nil { if errors.Is(err, models.ErrOAuthPendingNotFound) { c.JSON(http.StatusBadRequest, gin.H{"error": "登录会话无效或已使用,请重新发起 OAuth"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } email = strings.ToLower(strings.TrimSpace(email)) acc, err := models.GetAccountByEmail(email) if err != nil && !errors.Is(err, sql.ErrNoRows) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if err == nil { acc.AuthType = models.AuthTypeOAuthMicrosoft acc.OAuthRefreshToken = refresh acc.SMTPHost = msSMTPHost acc.SMTPPort = msSMTPPort acc.IMAPHost = msIMAPHost acc.IMAPPort = msIMAPPort acc.UseTLS = true if err := models.UpdateAccount(acc); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } acc.Password = "" acc.OAuthRefreshToken = "" c.JSON(http.StatusOK, acc) return } local := email if at := strings.IndexByte(email, '@'); at > 0 { local = email[:at] } na := &models.Account{ Name: local, Email: email, Password: "", SMTPHost: msSMTPHost, SMTPPort: msSMTPPort, IMAPHost: msIMAPHost, IMAPPort: msIMAPPort, UseTLS: true, AuthType: models.AuthTypeOAuthMicrosoft, OAuthRefreshToken: refresh, } if err := models.CreateAccount(na); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } na.Password = "" na.OAuthRefreshToken = "" c.JSON(http.StatusOK, na) } }