mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 22:06:38 +08:00
449 lines
14 KiB
Go
449 lines
14 KiB
Go
// Copyright 2026 Arctel.net
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
// Package controller provides HTTP handlers and middlewares for the auth plugin.
|
|
package controller
|
|
|
|
import (
|
|
"Wavelet/core/contracts"
|
|
"Wavelet/pkg/logger"
|
|
"Wavelet/pkg/response"
|
|
"Wavelet/plugins/domain/auth/consts"
|
|
"Wavelet/plugins/domain/auth/dao"
|
|
"Wavelet/plugins/domain/auth/model/do"
|
|
"Wavelet/plugins/domain/auth/model/dto"
|
|
"Wavelet/plugins/domain/auth/model/entity"
|
|
"Wavelet/plugins/domain/auth/service"
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gin-contrib/sessions"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// OAuthHandler handles OAuth authentication endpoints.
|
|
type OAuthHandler struct {
|
|
oauthSvc *service.OAuthService
|
|
sessionSvc *service.SessionService
|
|
dao *dao.DAO
|
|
}
|
|
|
|
// NewOAuthHandler creates a new OAuthHandler.
|
|
func NewOAuthHandler(oauthSvc *service.OAuthService, sessionSvc *service.SessionService, d *dao.DAO) *OAuthHandler {
|
|
return &OAuthHandler{
|
|
oauthSvc: oauthSvc,
|
|
sessionSvc: sessionSvc,
|
|
dao: d,
|
|
}
|
|
}
|
|
|
|
// GetLoginSources 获取可用登录源列表
|
|
// @Summary 获取可用登录源
|
|
// @Description 返回当前系统已启用的所有 OAuth 登录源,前端展示登录按钮列表时调用
|
|
// @Tags oauth
|
|
// @Produce json
|
|
// @Success 200 {object} response.Any{data=[]dto.AuthSourceView} "登录源列表"
|
|
// @Router /api/v1/oauth/sources [get]
|
|
func (h *OAuthHandler) GetLoginSources(c *gin.Context) {
|
|
sources, err := h.oauthSvc.ActiveLoginSources(c.Request.Context())
|
|
if err != nil {
|
|
response.AbortInternal(c, err.Error())
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, response.OK(sources))
|
|
}
|
|
|
|
// GetLoginURL 获取登录授权地址
|
|
// @Summary 获取登录授权地址
|
|
// @Description 根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用第一个启用的认证源。
|
|
// @Tags oauth
|
|
// @Produce json
|
|
// @Param source query string false "认证源名称,为空使用第一个启用的认证源"
|
|
// @Success 200 {object} response.Any{data=dto.OAuthAuthorizeResponse} "授权 URL"
|
|
// @Failure 400 {object} response.Any "认证源不存在或未配置"
|
|
// @Failure 500 {object} response.Any "构造 URL 失败"
|
|
// @Router /api/v1/oauth/login [get]
|
|
func (h *OAuthHandler) GetLoginURL(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
if !h.oauthSvc.IsOIDCLoginEnabled(ctx) {
|
|
response.AbortBadRequest(c, consts.ErrAuthSourceDisabled)
|
|
return
|
|
}
|
|
|
|
source, err := h.oauthSvc.ResolveAuthSource(ctx, c.Query("source"))
|
|
if err != nil {
|
|
response.AbortBadRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
if !source.IsActive {
|
|
response.AbortBadRequest(c, consts.ErrAuthSourceDisabled)
|
|
return
|
|
}
|
|
|
|
session := sessions.Default(c)
|
|
token, isNew := h.sessionSvc.EnsureSessionToken(session)
|
|
if isNew {
|
|
if err := session.Save(); err != nil {
|
|
response.AbortInternal(c, err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
userID := GetUserIDFromSession(session)
|
|
sessionHash := h.sessionSvc.HashSessionToken(token)
|
|
if err := h.oauthSvc.ReserveOAuthStateSlot(ctx, sessionHash); err != nil {
|
|
response.AbortBadRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
state := uuid.NewString()
|
|
payloadValue, err := (do.OAuthStatePayload{
|
|
SourceName: source.Name,
|
|
Purpose: consts.OAuthPurposeLogin,
|
|
UserID: userID,
|
|
SessionHash: sessionHash,
|
|
}).Encode()
|
|
if err != nil {
|
|
response.AbortInternal(c, err.Error())
|
|
return
|
|
}
|
|
stateKey := fmt.Sprintf(consts.OAuthStateCacheKeyFormat, state)
|
|
if cache := h.dao.Cache(); cache != nil {
|
|
if err := cache.Set(ctx, stateKey, payloadValue, consts.OAuthStateCacheKeyExpiration); err != nil {
|
|
response.AbortInternal(c, err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
authorizeURL, err := h.oauthSvc.BuildAuthorizeURL(ctx, source, state)
|
|
if err != nil {
|
|
response.AbortBadRequest(c, err.Error())
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, response.OK(dto.OAuthAuthorizeResponse{AuthorizeURL: authorizeURL}))
|
|
}
|
|
|
|
// Authorize 发起指定认证源授权
|
|
// @Summary 发起指定认证源授权
|
|
// @Description 根据指定认证源名称发起 OAuth 授权,支持 purpose 参数用于区分登录和账号绑定场景。认证源必须已启用。
|
|
// @Tags oauth
|
|
// @Produce json
|
|
// @Param source path string true "认证源名称"
|
|
// @Param purpose query string false "授权目的:login(登录)或 bind(绑定账号),默认 login"
|
|
// @Success 200 {object} response.Any{data=dto.OAuthAuthorizeResponse} "授权 URL"
|
|
// @Failure 400 {object} response.Any "认证源不存在或未启用"
|
|
// @Failure 500 {object} response.Any "构造 URL 失败"
|
|
// @Router /api/v1/oauth/{source}/authorize [get]
|
|
func (h *OAuthHandler) Authorize(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
if !h.oauthSvc.IsOIDCLoginEnabled(ctx) {
|
|
response.AbortBadRequest(c, consts.ErrAuthSourceDisabled)
|
|
return
|
|
}
|
|
|
|
source, err := h.oauthSvc.ResolveAuthSource(ctx, c.Param("source"))
|
|
if err != nil {
|
|
response.AbortBadRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
if !source.IsActive {
|
|
response.AbortBadRequest(c, consts.ErrAuthSourceDisabled)
|
|
return
|
|
}
|
|
purpose := strings.ToLower(strings.TrimSpace(c.Query("purpose")))
|
|
if purpose != consts.OAuthPurposeBind {
|
|
purpose = consts.OAuthPurposeLogin
|
|
}
|
|
|
|
session := sessions.Default(c)
|
|
userID := GetUserIDFromSession(session)
|
|
if purpose == consts.OAuthPurposeBind && userID == 0 {
|
|
response.AbortUnauthorized(c, consts.ErrUnAuthorized)
|
|
return
|
|
}
|
|
|
|
token, isNew := h.sessionSvc.EnsureSessionToken(session)
|
|
if isNew {
|
|
if err := session.Save(); err != nil {
|
|
response.AbortInternal(c, err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
sessionHash := h.sessionSvc.HashSessionToken(token)
|
|
if err := h.oauthSvc.ReserveOAuthStateSlot(ctx, sessionHash); err != nil {
|
|
response.AbortBadRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
state := uuid.NewString()
|
|
payloadValue, err := (do.OAuthStatePayload{
|
|
SourceName: source.Name,
|
|
Purpose: purpose,
|
|
UserID: userID,
|
|
SessionHash: sessionHash,
|
|
}).Encode()
|
|
if err != nil {
|
|
response.AbortInternal(c, err.Error())
|
|
return
|
|
}
|
|
stateKey := fmt.Sprintf(consts.OAuthStateCacheKeyFormat, state)
|
|
if cache := h.dao.Cache(); cache != nil {
|
|
if err := cache.Set(ctx, stateKey, payloadValue, consts.OAuthStateCacheKeyExpiration); err != nil {
|
|
response.AbortInternal(c, err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
authorizeURL, err := h.oauthSvc.BuildAuthorizeURL(ctx, source, state)
|
|
if err != nil {
|
|
response.AbortBadRequest(c, err.Error())
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, response.OK(dto.OAuthAuthorizeResponse{AuthorizeURL: authorizeURL}))
|
|
}
|
|
|
|
// Callback OAuth 回调处理
|
|
// @Summary OAuth 回调处理
|
|
// @Description 接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立会话。支持登录(login)和账号绑定(bind)两种场景。
|
|
// @Tags oauth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body dto.CallbackRequest true "回调请求参数"
|
|
// @Success 200 {object} response.Any{data=dto.OAuthCallbackResult} "登录或绑定成功"
|
|
// @Failure 400 {object} response.Any "state 无效、参数错误或认证源错误"
|
|
// @Failure 401 {object} response.Any "绑定场景未登录"
|
|
// @Failure 500 {object} response.Any "OAuth 认证失败或内部错误"
|
|
// @Router /api/v1/oauth/callback [post]
|
|
func (h *OAuthHandler) Callback(c *gin.Context) {
|
|
var req dto.CallbackRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.AbortBadRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
stateKey := fmt.Sprintf(consts.OAuthStateCacheKeyFormat, req.State)
|
|
var payloadRaw string
|
|
cache := h.dao.Cache()
|
|
if cache == nil {
|
|
response.AbortBadRequest(c, consts.ErrInvalidState)
|
|
return
|
|
}
|
|
if err := cache.Get(ctx, stateKey, &payloadRaw); err != nil {
|
|
response.AbortBadRequest(c, consts.ErrInvalidState)
|
|
return
|
|
}
|
|
_ = cache.Delete(ctx, stateKey)
|
|
|
|
payload, err := do.DecodeOAuthStatePayload(payloadRaw)
|
|
if err != nil {
|
|
response.AbortBadRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
session := sessions.Default(c)
|
|
currentUserID := GetUserIDFromSession(session)
|
|
|
|
if payload.Purpose == consts.OAuthPurposeBind && currentUserID == 0 {
|
|
response.AbortUnauthorized(c, consts.ErrUnAuthorized)
|
|
return
|
|
}
|
|
|
|
token, ok := session.Get(consts.SessionTokenKey).(string)
|
|
if !ok || token == "" {
|
|
response.AbortBadRequest(c, consts.ErrInvalidSessionContext)
|
|
return
|
|
}
|
|
|
|
if h.sessionSvc.HashSessionToken(token) != payload.SessionHash {
|
|
response.AbortBadRequest(c, consts.ErrSessionMismatchForOAuth)
|
|
return
|
|
}
|
|
|
|
if payload.Purpose == consts.OAuthPurposeBind && currentUserID != payload.UserID {
|
|
response.AbortBadRequest(c, consts.ErrUserContextMismatch)
|
|
return
|
|
}
|
|
|
|
if !h.oauthSvc.IsOIDCLoginEnabled(ctx) {
|
|
response.AbortBadRequest(c, consts.ErrAuthSourceDisabled)
|
|
return
|
|
}
|
|
|
|
source, err := h.oauthSvc.ResolveAuthSource(ctx, payload.SourceName)
|
|
if err != nil {
|
|
response.AbortBadRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
if !source.IsActive {
|
|
response.AbortBadRequest(c, consts.ErrAuthSourceDisabled)
|
|
return
|
|
}
|
|
|
|
redirectURL, err := h.oauthSvc.GetFrontendLoginRedirectURL(ctx)
|
|
if err != nil {
|
|
response.AbortBadRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
userInfo, err := h.oauthSvc.BuildOAuthUserInfo(ctx, source, req.Code, req.State, redirectURL)
|
|
if err != nil {
|
|
response.AbortInternal(c, err.Error())
|
|
return
|
|
}
|
|
if err := h.oauthSvc.NormalizeOAuthUserInfo(userInfo); err != nil {
|
|
response.AbortBadRequest(c, err.Error())
|
|
return
|
|
}
|
|
if userInfo.Sub == "" {
|
|
userInfo.Sub = userInfo.Username
|
|
}
|
|
|
|
if payload.Purpose == consts.OAuthPurposeBind {
|
|
h.handleCallbackBind(ctx, c, source, userInfo)
|
|
return
|
|
}
|
|
|
|
h.handleCallbackLogin(ctx, c, source, userInfo)
|
|
}
|
|
|
|
func (h *OAuthHandler) handleCallbackBind(ctx context.Context, c *gin.Context, source *entity.AuthSource, userInfo *contracts.OAuthUserInfoDTO) {
|
|
userID := GetUserIDFromContext(c)
|
|
if userID == 0 {
|
|
response.AbortUnauthorized(c, consts.ErrUnAuthorized)
|
|
return
|
|
}
|
|
user, err := h.dao.GetUserByID(ctx, userID)
|
|
if err != nil {
|
|
response.AbortInternal(c, err.Error())
|
|
return
|
|
}
|
|
if err := h.oauthSvc.BindExternalAccount(ctx, source.ID, user.ID, userInfo); err != nil {
|
|
response.AbortBadRequest(c, err.Error())
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, response.OK(buildCallbackResult(user, "bound")))
|
|
}
|
|
|
|
func (h *OAuthHandler) handleCallbackLogin(ctx context.Context, c *gin.Context, source *entity.AuthSource, userInfo *contracts.OAuthUserInfoDTO) {
|
|
user, ok, err := h.oauthSvc.AuthenticateOrRegisterUser(ctx, source, userInfo)
|
|
if err != nil {
|
|
response.AbortInternal(c, err.Error())
|
|
return
|
|
}
|
|
if !ok || user == nil {
|
|
c.JSON(http.StatusOK, response.OK(buildCallbackResult(nil, "need_bind")))
|
|
return
|
|
}
|
|
|
|
session := sessions.Default(c)
|
|
isSessionCookie, err := h.sessionSvc.ApplyLoginSession(ctx, session, user)
|
|
if err != nil {
|
|
response.AbortInternal(c, err.Error())
|
|
return
|
|
}
|
|
if isSessionCookie {
|
|
h.sessionSvc.StripCookieMaxAgeAndExpires(c.Writer.Header(), h.sessionSvc.Config().SessionCookieName)
|
|
}
|
|
|
|
h.dao.SetCachedUser(ctx, user.ID, user)
|
|
logger.InfoF(ctx, "[LoginAudit] successful OAuth login via source: %s, external ID: %s, user: %s, ID: %d, IP: %s", source.Name, userInfo.Sub, user.Username, user.ID, c.ClientIP())
|
|
|
|
c.JSON(http.StatusOK, response.OK(buildCallbackResult(user, "logged_in")))
|
|
}
|
|
|
|
func buildCallbackResult(user *contracts.UserDTO, status string) dto.OAuthCallbackResult {
|
|
result := dto.OAuthCallbackResult{Status: status}
|
|
if user != nil {
|
|
info := dto.BuildBasicUserInfo(user, false)
|
|
result.User = &info
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Logout 退出登录
|
|
// @Summary 退出登录
|
|
// @Description 清除当前用户的登录会话,完成退出。清除 Cookie 中的 Session 数据。
|
|
// @Tags oauth
|
|
// @Produce json
|
|
// @Security SessionCookie
|
|
// @Success 200 {object} response.Any{data=string} "退出成功"
|
|
// @Failure 500 {object} response.Any "Session 清除失败"
|
|
// @Router /api/v1/oauth/logout [get]
|
|
func (h *OAuthHandler) Logout(c *gin.Context) {
|
|
session := sessions.Default(c)
|
|
userID := session.Get(consts.UserIDKey)
|
|
username := session.Get(consts.UserNameKey)
|
|
if userID != nil {
|
|
logger.InfoF(c.Request.Context(), "[LoginAudit] user logged out: %v, ID: %v, IP: %s", username, userID, c.ClientIP())
|
|
if id := dto.ParseUserID(userID); id > 0 {
|
|
h.dao.InvalidateCachedUser(c.Request.Context(), id)
|
|
}
|
|
}
|
|
session.Options(h.sessionSvc.GetSessionOptions(-1))
|
|
session.Clear()
|
|
if err := session.Save(); err != nil {
|
|
response.AbortInternal(c, err.Error())
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, response.OKNil())
|
|
}
|
|
|
|
// ListExternalAccounts 获取当前用户的外部帐号绑定列表
|
|
// @Summary 获取外部帐号列表
|
|
// @Description 返回当前登录用户已绑定的所有外部 OAuth 帐号信息,需要登录
|
|
// @Tags oauth
|
|
// @Produce json
|
|
// @Security SessionCookie
|
|
// @Success 200 {object} response.Any "外部帐号列表"
|
|
// @Failure 401 {object} response.Any "未登录"
|
|
// @Failure 500 {object} response.Any "内部错误"
|
|
// @Router /api/v1/oauth/external-accounts [get]
|
|
func (h *OAuthHandler) ListExternalAccounts(c *gin.Context) {
|
|
userID := GetUserIDFromContext(c)
|
|
accounts, err := h.oauthSvc.ListExternalAccounts(c.Request.Context(), userID)
|
|
if err != nil {
|
|
response.AbortInternal(c, err.Error())
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, response.OK(accounts))
|
|
}
|
|
|
|
// DeleteExternalAccount 解除外部帐号绑定
|
|
// @Summary 解除外部帐号绑定
|
|
// @Description 解除当前登录用户与指定外部帐号的绑定关系,需要登录
|
|
// @Tags oauth
|
|
// @Produce json
|
|
// @Security SessionCookie
|
|
// @Param id path uint64 true "外部帐号绑定记录 ID"
|
|
// @Success 200 {object} response.Any{data=string} "解除绑定成功"
|
|
// @Failure 400 {object} response.Any "ID 无效或解除失败"
|
|
// @Failure 401 {object} response.Any "未登录"
|
|
// @Router /api/v1/oauth/external-accounts/{id}/delete [post]
|
|
func (h *OAuthHandler) DeleteExternalAccount(c *gin.Context) {
|
|
userID := GetUserIDFromContext(c)
|
|
if userID == 0 {
|
|
response.AbortUnauthorized(c, consts.ErrUnAuthorized)
|
|
return
|
|
}
|
|
rawID := strings.TrimSpace(c.Param("id"))
|
|
id, err := strconv.ParseUint(rawID, 10, 64)
|
|
if err != nil || id == 0 {
|
|
response.AbortBadRequest(c, consts.ErrInvalidExternalAccountBindingID)
|
|
return
|
|
}
|
|
if err := h.oauthSvc.DeleteExternalAccount(c.Request.Context(), id, userID); err != nil {
|
|
response.AbortBadRequest(c, err.Error())
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, response.OKNil())
|
|
}
|