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