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