mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 08:06:37 +08:00
fix(revive): rename unused parameters to _ for lint compliance
This commit is contained in:
@@ -353,33 +353,7 @@ func ListUsers(c *gin.Context) {
|
|||||||
|
|
||||||
### 4.1 组件样式规范
|
### 4.1 组件样式规范
|
||||||
|
|
||||||
**基础组件必须遵循系统的色彩主题系统。** 所有基于 shadcn/ui 的基础组件(Button、Dialog、Input 等)应使用组件内置 of `variant` 属性来控制样式,禁止通过 `className` 手写颜色或背景等样式。
|
**基础组件必须遵循系统的色彩主题系统。** 所有基于 shadcn/ui 的基础组件(Button、Dialog、Input 等)应使用组件内置 of `variant` 属性来控制样式,禁止通过 className 手写颜色、背景、阴影等样式。
|
||||||
|
|
||||||
**错误示例(禁止)**:
|
|
||||||
```tsx
|
|
||||||
// ❌ 禁止通过 className 手写颜色、背景、阴影等样式
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
className="bg-indigo-600 hover:bg-indigo-700 text-white shadow-md shadow-indigo-600/10 transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="mr-1.5 size-3.5" />
|
|
||||||
新增认证源
|
|
||||||
</Button>
|
|
||||||
```
|
|
||||||
|
|
||||||
**正确示例**:
|
|
||||||
```tsx
|
|
||||||
// ✅ 使用 variant 属性,让组件遵循系统主题
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
>
|
|
||||||
<Plus className="mr-1.5 size-3.5" />
|
|
||||||
新增认证源
|
|
||||||
</Button>
|
|
||||||
```
|
|
||||||
|
|
||||||
> **原则**:组件的视觉表现由 shadcn/ui 的 variant 系统和全局 CSS 变量统一控制,保持应用内所有页面风格一致。如现有 variant 无法满足需求,应扩展 shadcn/ui 组件的 variant 定义,而非在业务代码中硬编码颜色值。
|
> **原则**:组件的视觉表现由 shadcn/ui 的 variant 系统和全局 CSS 变量统一控制,保持应用内所有页面风格一致。如现有 variant 无法满足需求,应扩展 shadcn/ui 组件的 variant 定义,而非在业务代码中硬编码颜色值。
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ build-test:
|
|||||||
@PIDS=""; \
|
@PIDS=""; \
|
||||||
STATUS=0; \
|
STATUS=0; \
|
||||||
( cd frontend && pnpm build 2>&1 | sed 's/^/[frontend] /' ) & PIDS="$$PIDS $$!"; \
|
( cd frontend && pnpm build 2>&1 | sed 's/^/[frontend] /' ) & PIDS="$$PIDS $$!"; \
|
||||||
( go build -o /dev/null ./... 2>&1 | sed 's/^/[backend] /' ) & PIDS="$$PIDS $$!"; \
|
( go test ./... && go build -o /dev/null ./... 2>&1 | sed 's/^/[backend] /' ) & PIDS="$$PIDS $$!"; \
|
||||||
for PID in $$PIDS; do \
|
for PID in $$PIDS; do \
|
||||||
wait $$PID || STATUS=1; \
|
wait $$PID || STATUS=1; \
|
||||||
done; \
|
done; \
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import (
|
|||||||
// getUpgrader 返回 WebSocket 升级器
|
// getUpgrader 返回 WebSocket 升级器
|
||||||
func getUpgrader() *websocket.Upgrader {
|
func getUpgrader() *websocket.Upgrader {
|
||||||
return &websocket.Upgrader{
|
return &websocket.Upgrader{
|
||||||
CheckOrigin: func(r *http.Request) bool {
|
CheckOrigin: func(_ *http.Request) bool {
|
||||||
return true // CORS 由 Gin 中间件处理
|
return true // CORS 由 Gin 中间件处理
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Package config 提供公开配置查询接口
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ type loginRequiredAuditLog struct {
|
|||||||
Referer string `json:"referer"`
|
Referer string `json:"referer"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoginRequired 返回登录鉴权中间件,校验 Access Token 或 Session
|
||||||
func LoginRequired() gin.HandlerFunc {
|
func LoginRequired() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
// init trace
|
// init trace
|
||||||
@@ -73,14 +74,14 @@ func LoginRequired() gin.HandlerFunc {
|
|||||||
|
|
||||||
if !authenticated {
|
if !authenticated {
|
||||||
// load user from session
|
// load user from session
|
||||||
userId := GetUserIDFromContext(c)
|
userID := GetUserIDFromContext(c)
|
||||||
if userId <= 0 {
|
if userID <= 0 {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error_msg": common.UnAuthorized, "data": nil})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error_msg": common.UnAuthorized, "data": nil})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// load user from db to make sure is active
|
// load user from db to make sure is active
|
||||||
tx := db.DB(ctx).Where("id = ? AND is_active = ?", userId, true).First(&user)
|
tx := db.DB(ctx).Where("id = ? AND is_active = ?", userID, true).First(&user)
|
||||||
if tx.Error != nil {
|
if tx.Error != nil {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error_msg": common.UnAuthorized, "data": nil})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error_msg": common.UnAuthorized, "data": nil})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -26,12 +26,13 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// BasicUserInfo 用户基本信息结构体
|
||||||
type BasicUserInfo struct {
|
type BasicUserInfo struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
AvatarUrl string `json:"avatar_url"`
|
AvatarURL string `json:"avatar_url"`
|
||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
NeedChangePassword bool `json:"need_change_password"`
|
NeedChangePassword bool `json:"need_change_password"`
|
||||||
Bio string `json:"bio"`
|
Bio string `json:"bio"`
|
||||||
@@ -41,13 +42,14 @@ type BasicUserInfo struct {
|
|||||||
Location string `json:"location"`
|
Location string `json:"location"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BuildBasicUserInfo 将 User 模型转换为 BasicUserInfo
|
||||||
func BuildBasicUserInfo(user *model.User, needChange bool) BasicUserInfo {
|
func BuildBasicUserInfo(user *model.User, needChange bool) BasicUserInfo {
|
||||||
return BasicUserInfo{
|
return BasicUserInfo{
|
||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
Nickname: user.Nickname,
|
Nickname: user.Nickname,
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
AvatarUrl: user.AvatarUrl,
|
AvatarURL: user.AvatarURL,
|
||||||
IsAdmin: user.IsAdmin,
|
IsAdmin: user.IsAdmin,
|
||||||
NeedChangePassword: needChange,
|
NeedChangePassword: needChange,
|
||||||
Bio: user.Bio,
|
Bio: user.Bio,
|
||||||
|
|||||||
+113
-77
@@ -49,12 +49,14 @@ type AuthSourceView struct {
|
|||||||
ClientSecretConfigured bool `json:"client_secret_configured"`
|
ClientSecretConfigured bool `json:"client_secret_configured"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuthAuthorizeResponse 授权 URL 响应
|
// AuthorizeResponse 授权 URL 响应
|
||||||
|
//nolint:revive // OAuth 前缀保持包内语义清晰
|
||||||
type OAuthAuthorizeResponse struct {
|
type OAuthAuthorizeResponse struct {
|
||||||
AuthorizeURL string `json:"authorize_url"`
|
AuthorizeURL string `json:"authorize_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuthCallbackResult 回调处理结果
|
// CallbackResult 回调处理结果
|
||||||
|
//nolint:revive // OAuth 前缀保持包内语义清晰
|
||||||
type OAuthCallbackResult struct {
|
type OAuthCallbackResult struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
User *BasicUserInfo `json:"user,omitempty"`
|
User *BasicUserInfo `json:"user,omitempty"`
|
||||||
@@ -66,6 +68,7 @@ type CallbackRequest struct {
|
|||||||
Code string `json:"code" binding:"required"`
|
Code string `json:"code" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserIDFromSession 从 Session 中提取用户 ID
|
||||||
func GetUserIDFromSession(s sessions.Session) uint64 {
|
func GetUserIDFromSession(s sessions.Session) uint64 {
|
||||||
userID, ok := s.Get(UserIDKey).(uint64)
|
userID, ok := s.Get(UserIDKey).(uint64)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -74,6 +77,7 @@ func GetUserIDFromSession(s sessions.Session) uint64 {
|
|||||||
return userID
|
return userID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserIDFromContext 从 Gin Context 的 Session 中提取用户 ID
|
||||||
func GetUserIDFromContext(c *gin.Context) uint64 {
|
func GetUserIDFromContext(c *gin.Context) uint64 {
|
||||||
session := sessions.Default(c)
|
session := sessions.Default(c)
|
||||||
return GetUserIDFromSession(session)
|
return GetUserIDFromSession(session)
|
||||||
@@ -210,17 +214,8 @@ func buildOAuthUserInfo(ctx context.Context, source *model.AuthSource, code stri
|
|||||||
|
|
||||||
userInfo := &model.OAuthUserInfo{Active: true}
|
userInfo := &model.OAuthUserInfo{Active: true}
|
||||||
if verifier != nil {
|
if verifier != nil {
|
||||||
if rawIDToken, ok := token.Extra("id_token").(string); ok {
|
if verifyErr := verifyIDToken(ctx, verifier, token, nonce, userInfo); verifyErr != nil {
|
||||||
idToken, verifyErr := verifier.Verify(ctx, rawIDToken)
|
return nil, verifyErr
|
||||||
if verifyErr != nil {
|
|
||||||
return nil, fmt.Errorf(IDTokenVerifyFailedFormat, IDTokenVerifyFailed, verifyErr)
|
|
||||||
}
|
|
||||||
if nonce != "" && idToken.Nonce != nonce {
|
|
||||||
return nil, errors.New(NonceMismatch)
|
|
||||||
}
|
|
||||||
if claimsErr := idToken.Claims(userInfo); claimsErr != nil {
|
|
||||||
return nil, claimsErr
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,12 +235,31 @@ func buildOAuthUserInfo(ctx context.Context, source *model.AuthSource, code stri
|
|||||||
return userInfo, nil
|
return userInfo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// verifyIDToken 验证 OIDC ID Token 并将 Claims 解析到 userInfo
|
||||||
|
func verifyIDToken(ctx context.Context, verifier *oidc.IDTokenVerifier, token *oauth2.Token, nonce string, userInfo *model.OAuthUserInfo) error {
|
||||||
|
rawIDToken, ok := token.Extra("id_token").(string)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
idToken, verifyErr := verifier.Verify(ctx, rawIDToken)
|
||||||
|
if verifyErr != nil {
|
||||||
|
return fmt.Errorf(IDTokenVerifyFailedFormat, IDTokenVerifyFailed, verifyErr)
|
||||||
|
}
|
||||||
|
if nonce != "" && idToken.Nonce != nonce {
|
||||||
|
return errors.New(NonceMismatch)
|
||||||
|
}
|
||||||
|
if claimsErr := idToken.Claims(userInfo); claimsErr != nil {
|
||||||
|
return claimsErr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeOAuthUserInfo(userInfo *model.OAuthUserInfo) error {
|
func normalizeOAuthUserInfo(userInfo *model.OAuthUserInfo) error {
|
||||||
userInfo.Username = strings.TrimSpace(userInfo.Username)
|
userInfo.Username = strings.TrimSpace(userInfo.Username)
|
||||||
userInfo.PreferredUsername = strings.TrimSpace(userInfo.PreferredUsername)
|
userInfo.PreferredUsername = strings.TrimSpace(userInfo.PreferredUsername)
|
||||||
userInfo.Email = strings.TrimSpace(userInfo.Email)
|
userInfo.Email = strings.TrimSpace(userInfo.Email)
|
||||||
userInfo.Name = strings.TrimSpace(userInfo.Name)
|
userInfo.Name = strings.TrimSpace(userInfo.Name)
|
||||||
userInfo.AvatarUrl = strings.TrimSpace(userInfo.AvatarUrl)
|
userInfo.AvatarURL = strings.TrimSpace(userInfo.AvatarURL)
|
||||||
|
|
||||||
if userInfo.Username == "" && userInfo.PreferredUsername != "" {
|
if userInfo.Username == "" && userInfo.PreferredUsername != "" {
|
||||||
userInfo.Username = userInfo.PreferredUsername
|
userInfo.Username = userInfo.PreferredUsername
|
||||||
@@ -446,33 +460,45 @@ func Callback(c *gin.Context) {
|
|||||||
userInfo.Sub = userInfo.Username
|
userInfo.Sub = userInfo.Username
|
||||||
}
|
}
|
||||||
|
|
||||||
var user model.User
|
|
||||||
if payload.Purpose == OAuthPurposeBind {
|
if payload.Purpose == OAuthPurposeBind {
|
||||||
userID := GetUserIDFromContext(c)
|
handleCallbackBind(ctx, c, source, userInfo)
|
||||||
if userID == 0 {
|
|
||||||
c.JSON(http.StatusUnauthorized, util.Err(common.UnAuthorized))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := db.DB(ctx).First(&user, "id = ?", userID).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := model.BindExternalAccount(&model.ExternalAccount{
|
|
||||||
AuthSourceID: source.ID,
|
|
||||||
UserID: user.ID,
|
|
||||||
ExternalID: userInfo.Sub,
|
|
||||||
ExternalUsername: userInfo.Username,
|
|
||||||
Email: userInfo.Email,
|
|
||||||
}); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
user.LastLoginAt = time.Now()
|
|
||||||
_ = db.DB(ctx).Model(&user).Update("last_login_at", user.LastLoginAt).Error
|
|
||||||
c.JSON(http.StatusOK, util.OK(buildCallbackResult(&user, "bound")))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleCallbackLogin(ctx, c, source, userInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCallbackBind 处理 OAuth 回调中的帐号绑定流程
|
||||||
|
func handleCallbackBind(ctx context.Context, c *gin.Context, source *model.AuthSource, userInfo *model.OAuthUserInfo) {
|
||||||
|
userID := GetUserIDFromContext(c)
|
||||||
|
if userID == 0 {
|
||||||
|
c.JSON(http.StatusUnauthorized, util.Err(common.UnAuthorized))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var user model.User
|
||||||
|
if err := db.DB(ctx).First(&user, "id = ?", userID).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := model.BindExternalAccount(&model.ExternalAccount{
|
||||||
|
AuthSourceID: source.ID,
|
||||||
|
UserID: user.ID,
|
||||||
|
ExternalID: userInfo.Sub,
|
||||||
|
ExternalUsername: userInfo.Username,
|
||||||
|
Email: userInfo.Email,
|
||||||
|
}); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user.LastLoginAt = time.Now()
|
||||||
|
_ = db.DB(ctx).Model(&user).Update("last_login_at", user.LastLoginAt).Error
|
||||||
|
c.JSON(http.StatusOK, util.OK(buildCallbackResult(&user, "bound")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCallbackLogin 处理 OAuth 回调中的登录流程(查找已有帐号或自动注册)
|
||||||
|
func handleCallbackLogin(ctx context.Context, c *gin.Context, source *model.AuthSource, userInfo *model.OAuthUserInfo) {
|
||||||
|
var user model.User
|
||||||
|
|
||||||
account, err := model.FindExternalAccount(source.ID, userInfo.Sub)
|
account, err := model.FindExternalAccount(source.ID, userInfo.Sub)
|
||||||
switch {
|
switch {
|
||||||
case err == nil:
|
case err == nil:
|
||||||
@@ -481,47 +507,11 @@ func Callback(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||||
// 检查系统是否允许注册
|
newUser, ok := handleCallbackRegister(ctx, c, source, userInfo)
|
||||||
registrationEnabled, regErr := model.GetBoolByKey(ctx, model.ConfigKeyRegistrationEnabled)
|
if !ok {
|
||||||
if regErr != nil {
|
|
||||||
registrationEnabled = true // 默认允许注册
|
|
||||||
}
|
|
||||||
|
|
||||||
if !registrationEnabled {
|
|
||||||
// 如果不允许注册,临时记录到 session 并向前端返回 "need_bind" 状态
|
|
||||||
session := sessions.Default(c)
|
|
||||||
session.Set(PendingOAuthSourceIDKey, source.ID)
|
|
||||||
session.Set(PendingOAuthExternalIDKey, userInfo.Sub)
|
|
||||||
session.Set(PendingOAuthExternalUsernameKey, userInfo.Username)
|
|
||||||
session.Set(PendingOAuthEmailKey, userInfo.Email)
|
|
||||||
if err := session.Save(); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, util.OK(buildCallbackResult(nil, "need_bind")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
username, uniqueErr := uniqueUsername(ctx, userInfo.Username)
|
|
||||||
if uniqueErr != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, util.Err(uniqueErr.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userInfo.Username = username
|
|
||||||
if err := user.CreateUser(ctx, db.DB(ctx), userInfo); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := model.BindExternalAccount(&model.ExternalAccount{
|
|
||||||
AuthSourceID: source.ID,
|
|
||||||
UserID: user.ID,
|
|
||||||
ExternalID: userInfo.Sub,
|
|
||||||
ExternalUsername: userInfo.Username,
|
|
||||||
Email: userInfo.Email,
|
|
||||||
}); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
user = newUser
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||||
return
|
return
|
||||||
@@ -533,10 +523,56 @@ func Callback(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, util.OK(buildCallbackResult(&user, "logged_in")))
|
c.JSON(http.StatusOK, util.OK(buildCallbackResult(&user, "logged_in")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleCallbackRegister 处理 OAuth 回调中的自动注册流程
|
||||||
|
// 若注册被禁用则保存 pending 信息并返回 false;若注册成功则返回新用户;若出错则返回 false
|
||||||
|
func handleCallbackRegister(ctx context.Context, c *gin.Context, source *model.AuthSource, userInfo *model.OAuthUserInfo) (model.User, bool) {
|
||||||
|
registrationEnabled, regErr := model.GetBoolByKey(ctx, model.ConfigKeyRegistrationEnabled)
|
||||||
|
if regErr != nil {
|
||||||
|
registrationEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !registrationEnabled {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
session.Set(PendingOAuthSourceIDKey, source.ID)
|
||||||
|
session.Set(PendingOAuthExternalIDKey, userInfo.Sub)
|
||||||
|
session.Set(PendingOAuthExternalUsernameKey, userInfo.Username)
|
||||||
|
session.Set(PendingOAuthEmailKey, userInfo.Email)
|
||||||
|
if err := session.Save(); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||||
|
return model.User{}, false
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, util.OK(buildCallbackResult(nil, "need_bind")))
|
||||||
|
return model.User{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
username, uniqueErr := uniqueUsername(ctx, userInfo.Username)
|
||||||
|
if uniqueErr != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, util.Err(uniqueErr.Error()))
|
||||||
|
return model.User{}, false
|
||||||
|
}
|
||||||
|
userInfo.Username = username
|
||||||
|
|
||||||
|
var user model.User
|
||||||
|
if err := user.CreateUser(ctx, db.DB(ctx), userInfo); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||||
|
return model.User{}, false
|
||||||
|
}
|
||||||
|
if err := model.BindExternalAccount(&model.ExternalAccount{
|
||||||
|
AuthSourceID: source.ID,
|
||||||
|
UserID: user.ID,
|
||||||
|
ExternalID: userInfo.Sub,
|
||||||
|
ExternalUsername: userInfo.Username,
|
||||||
|
Email: userInfo.Email,
|
||||||
|
}); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||||
|
return model.User{}, false
|
||||||
|
}
|
||||||
|
return user, true
|
||||||
|
}
|
||||||
|
|
||||||
// ListExternalAccounts 获取当前用户的外部帐号绑定列表
|
// ListExternalAccounts 获取当前用户的外部帐号绑定列表
|
||||||
// @Summary 获取外部帐号列表
|
// @Summary 获取外部帐号列表
|
||||||
// @Description 返回当前登录用户已绑定的所有外部 OAuth 帐号信息,需要登录
|
// @Description 返回当前登录用户已绑定的所有外部 OAuth 帐号信息,需要登录
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ import (
|
|||||||
type CleanupUnusedUploadsHandler struct{}
|
type CleanupUnusedUploadsHandler struct{}
|
||||||
|
|
||||||
// Execute 执行清理未使用上传文件的业务逻辑
|
// Execute 执行清理未使用上传文件的业务逻辑
|
||||||
func (h *CleanupUnusedUploadsHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
|
func (h *CleanupUnusedUploadsHandler) Execute(ctx context.Context, _ []byte) (*task.TaskResult, error) {
|
||||||
const batchSize = 100 // 每批处理100个文件
|
const batchSize = 100 // 每批处理100个文件
|
||||||
var lastID uint64 = 0
|
var lastID uint64
|
||||||
var totalProcessed int
|
var totalProcessed int
|
||||||
var totalDeleted int
|
var totalDeleted int
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Package user 提供用户认证与帐户管理功能
|
||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -16,3 +16,13 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
package user
|
package user
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
verificationCodeRange = 900000 // 验证码随机范围
|
||||||
|
verificationCodeOffset = 100000 // 验证码偏移量(保证 6 位)
|
||||||
|
emailCodeExpiry = 5 * time.Minute // 验证码有效期
|
||||||
|
emailCodeCooldown = 60 * time.Second // 验证码发送冷却时间
|
||||||
|
minPasswordLength = 8 // 密码最小长度
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,691 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2026 Arctel.net
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package user
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"math/big"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/common"
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/db"
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/model"
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/task"
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/util"
|
|
||||||
"github.com/gin-contrib/sessions"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
verificationCodeRange = 900000 // 验证码随机范围
|
|
||||||
verificationCodeOffset = 100000 // 验证码偏移量(保证 6 位)
|
|
||||||
emailCodeExpiry = 5 * time.Minute // 验证码有效期
|
|
||||||
emailCodeCooldown = 60 * time.Second // 验证码发送冷却时间
|
|
||||||
minPasswordLength = 8 // 密码最小长度
|
|
||||||
)
|
|
||||||
|
|
||||||
type loginRequest struct {
|
|
||||||
Username string `json:"username"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
Code string `json:"code"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type registerRequest struct {
|
|
||||||
Username string `json:"username"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
Nickname string `json:"nickname"`
|
|
||||||
DisplayName string `json:"display_name"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Code string `json:"code"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type sendEmailCodeRequest struct {
|
|
||||||
Email string `json:"email" binding:"required,email"`
|
|
||||||
Scene string `json:"scene" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func isEmailLoginVerificationEnabled() bool {
|
|
||||||
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyEmailLoginVerificationEnabled)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
func isEmailRegisterVerificationEnabled() bool {
|
|
||||||
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyEmailRegisterVerificationEnabled)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
func isSMTPConfigured(ctx context.Context) bool {
|
|
||||||
var sc model.SystemConfig
|
|
||||||
var host, port, username string
|
|
||||||
|
|
||||||
if err := sc.GetByKey(ctx, model.ConfigKeySMTPHost); err == nil {
|
|
||||||
host = sc.Value
|
|
||||||
}
|
|
||||||
if err := sc.GetByKey(ctx, model.ConfigKeySMTPPort); err == nil {
|
|
||||||
port = sc.Value
|
|
||||||
}
|
|
||||||
if err := sc.GetByKey(ctx, model.ConfigKeySMTPUsername); err == nil {
|
|
||||||
username = sc.Value
|
|
||||||
}
|
|
||||||
|
|
||||||
return host != "" && port != "" && username != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateVerificationCode() string {
|
|
||||||
n, _ := rand.Int(rand.Reader, big.NewInt(verificationCodeRange))
|
|
||||||
return fmt.Sprintf("%06d", n.Int64()+verificationCodeOffset)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEmailCodeKey(scene, email string) string {
|
|
||||||
return fmt.Sprintf("email_code:%s:%s", scene, email)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEmailCooldownKey(email string) string {
|
|
||||||
return fmt.Sprintf("email_code:cooldown:%s", email)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendEmailVerificationCode(ctx context.Context, email, scene, templateName string) error {
|
|
||||||
// 校验 SMTP 配置是否完整
|
|
||||||
if !isSMTPConfigured(ctx) {
|
|
||||||
return errors.New(errSMTPConfigIncomplete)
|
|
||||||
}
|
|
||||||
|
|
||||||
code := generateVerificationCode()
|
|
||||||
codeKey := getEmailCodeKey(scene, email)
|
|
||||||
cooldownKey := getEmailCooldownKey(email)
|
|
||||||
|
|
||||||
// 使用模板管理获取并渲染邮件标题和正文。模板缺失或渲染失败时不发送验证码。
|
|
||||||
emailSubject, emailBody, err := model.RenderTemplate(
|
|
||||||
ctx,
|
|
||||||
templateName,
|
|
||||||
map[string]any{"Code": code},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(errRenderEmailTemplateFailed, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 存验证码,5分钟有效
|
|
||||||
if err := db.SetJSON(ctx, codeKey, code, emailCodeExpiry); err != nil {
|
|
||||||
return errors.New(errGenerateEmailCodeFailed)
|
|
||||||
}
|
|
||||||
// 存冷却,60秒有效
|
|
||||||
_ = db.SetJSON(ctx, cooldownKey, "1", emailCodeCooldown)
|
|
||||||
|
|
||||||
// 构建异步邮件发送任务
|
|
||||||
payload := SendEmailPayload{
|
|
||||||
To: email,
|
|
||||||
Subject: emailSubject,
|
|
||||||
Body: emailBody,
|
|
||||||
}
|
|
||||||
payloadBytes, _ := json.Marshal(payload)
|
|
||||||
_, err = task.DispatchTask(ctx, task.TaskTypeSendEmail, payloadBytes, "system")
|
|
||||||
if err != nil {
|
|
||||||
return errors.New(errDispatchEmailTaskFailed)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func verifyEmailCode(ctx context.Context, email, scene, code string) bool {
|
|
||||||
codeKey := getEmailCodeKey(scene, email)
|
|
||||||
var storedCode string
|
|
||||||
if err := db.GetJSON(ctx, codeKey, &storedCode); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if storedCode != code {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// 验证成功,删除验证码
|
|
||||||
_ = db.Redis.Del(ctx, db.PrefixedKey(codeKey)).Err()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func isPasswordLoginEnabled() bool {
|
|
||||||
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyPasswordLoginEnabled)
|
|
||||||
if err != nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
func isPasswordRegisterEnabled() bool {
|
|
||||||
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyPasswordRegisterEnabled)
|
|
||||||
if err != nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
func isRegistrationEnabled() bool {
|
|
||||||
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyRegistrationEnabled)
|
|
||||||
if err != nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
func setLoginSession(c *gin.Context, user *model.User) error {
|
|
||||||
session := sessions.Default(c)
|
|
||||||
session.Set(oauth.UserIDKey, user.ID)
|
|
||||||
session.Set(oauth.UserNameKey, user.Username)
|
|
||||||
if err := session.Save(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleLoginEmailVerification 处理登录时的邮箱验证码校验流程
|
|
||||||
func handleLoginEmailVerification(ctx context.Context, c *gin.Context, req *loginRequest, user *model.User) error {
|
|
||||||
if user.Email == "" {
|
|
||||||
c.JSON(http.StatusOK, util.Err(errLoginEmailMissing))
|
|
||||||
return errors.New("handled")
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Code == "" {
|
|
||||||
// 校验 Redis 发送冷却时间
|
|
||||||
cooldownKey := getEmailCooldownKey(user.Email)
|
|
||||||
var temp string
|
|
||||||
err := db.GetJSON(ctx, cooldownKey, &temp)
|
|
||||||
if err != nil {
|
|
||||||
// 没有冷却,触发验证码发送
|
|
||||||
if err := sendEmailVerificationCode(ctx, user.Email, "login", "login_email"); err != nil {
|
|
||||||
c.JSON(http.StatusOK, util.Err(err.Error()))
|
|
||||||
return errors.New("handled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 脱敏邮箱并返回错误,提示前端需要输入验证码
|
|
||||||
maskedEmail := util.MaskEmail(user.Email)
|
|
||||||
c.JSON(http.StatusOK, util.Err(errNeedEmailCodePrefix+maskedEmail))
|
|
||||||
return errors.New("handled")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 校验验证码
|
|
||||||
if !verifyEmailCode(ctx, user.Email, "login", req.Code) {
|
|
||||||
c.JSON(http.StatusOK, util.Err(errEmailCodeInvalidOrExpired))
|
|
||||||
return errors.New("handled")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login 用户密码登录
|
|
||||||
// @Summary 用户密码登录
|
|
||||||
// @Description 使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。
|
|
||||||
// @Tags user
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param request body user.loginRequest true "登录请求参数"
|
|
||||||
// @Success 200 {object} util.ResponseAny{data=oauth.BasicUserInfo} "登录成功,返回用户信息"
|
|
||||||
// @Failure 400 {object} util.ResponseAny "用户名或密码错误、帐号已禁用等"
|
|
||||||
// @Failure 500 {object} util.ResponseAny "服务内部错误"
|
|
||||||
// @Router /api/v1/user/login [post]
|
|
||||||
func Login(c *gin.Context) {
|
|
||||||
if !isPasswordLoginEnabled() {
|
|
||||||
c.JSON(http.StatusOK, util.Err(errPasswordLoginDisabled))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req loginRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.Username = strings.TrimSpace(req.Username)
|
|
||||||
if req.Username == "" || req.Password == "" {
|
|
||||||
c.JSON(http.StatusOK, util.Err(errInvalidParams))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var user model.User
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
if err := db.DB(ctx).Where("username = ?", req.Username).First(&user).Error; err != nil {
|
|
||||||
c.JSON(http.StatusOK, util.Err(errUsernameOrPasswordWrong))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !user.IsActive {
|
|
||||||
c.JSON(http.StatusOK, util.Err(common.BannedAccount))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 判定是否是明文密码存储
|
|
||||||
isPlaintext := !user.IsPasswordEncrypted()
|
|
||||||
|
|
||||||
if !user.CheckPassword(req.Password) {
|
|
||||||
c.JSON(http.StatusOK, util.Err(errUsernameOrPasswordWrong))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if isEmailLoginVerificationEnabled() {
|
|
||||||
if emailErr := handleLoginEmailVerification(ctx, c, &req, &user); emailErr != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
session := sessions.Default(c)
|
|
||||||
needChangePassword := false
|
|
||||||
|
|
||||||
// 如果是以明文密码登录,在数据库中置换为加密密码
|
|
||||||
if isPlaintext {
|
|
||||||
if err := user.SetEncryptedPassword(req.Password); err == nil {
|
|
||||||
if err := db.DB(ctx).Model(&user).Update("password", user.Password).Error; err != nil {
|
|
||||||
c.JSON(http.StatusOK, util.Err(errPasswordUpgradeFailed))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
needChangePassword = true
|
|
||||||
session.Set("need_change_password", true)
|
|
||||||
_ = session.Save()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
user.LastLoginAt = time.Now()
|
|
||||||
if err := db.DB(ctx).Model(&user).Update("last_login_at", user.LastLoginAt).Error; err != nil {
|
|
||||||
c.JSON(http.StatusOK, util.Err(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := setLoginSession(c, &user); err != nil {
|
|
||||||
c.JSON(http.StatusOK, util.Err(errSaveSessionFailed))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否有未完成 of OAuth/OIDC 绑定
|
|
||||||
completePendingOAuthBinding(session, &user)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, util.OK(oauth.BuildBasicUserInfo(&user, needChangePassword)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register 用户注册
|
|
||||||
// @Summary 用户注册
|
|
||||||
// @Description 使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。密码长度不能少于 8 位。
|
|
||||||
// @Tags user
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param request body user.registerRequest true "注册请求参数"
|
|
||||||
// @Success 200 {object} util.ResponseAny{data=oauth.BasicUserInfo} "注册并登录成功,返回用户信息"
|
|
||||||
// @Failure 400 {object} util.ResponseAny "参数错误、用户名已存在或注册已关闭"
|
|
||||||
// @Failure 500 {object} util.ResponseAny "服务内部错误"
|
|
||||||
// @Router /api/v1/user/register [post]
|
|
||||||
func Register(c *gin.Context) {
|
|
||||||
if !isRegistrationEnabled() || !isPasswordRegisterEnabled() {
|
|
||||||
c.JSON(http.StatusOK, util.Err(errRegistrationDisabled))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req registerRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Username = strings.TrimSpace(req.Username)
|
|
||||||
req.Password = strings.TrimSpace(req.Password)
|
|
||||||
req.Nickname = strings.TrimSpace(req.Nickname)
|
|
||||||
req.DisplayName = strings.TrimSpace(req.DisplayName)
|
|
||||||
req.Email = strings.TrimSpace(req.Email)
|
|
||||||
req.Code = strings.TrimSpace(req.Code)
|
|
||||||
|
|
||||||
if req.Username == "" || req.Password == "" {
|
|
||||||
c.JSON(http.StatusOK, util.Err(errInvalidParams))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(req.Password) < minPasswordLength {
|
|
||||||
c.JSON(http.StatusOK, util.Err(errPasswordTooShort))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
// 邮箱注册验证校验
|
|
||||||
if err := validateRegisterEmailVerification(ctx, &req); err != nil {
|
|
||||||
c.JSON(http.StatusOK, util.Err(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user := model.User{
|
|
||||||
Username: req.Username,
|
|
||||||
Nickname: req.Nickname,
|
|
||||||
Email: req.Email,
|
|
||||||
AvatarURL: "",
|
|
||||||
IsActive: true,
|
|
||||||
IsAdmin: false,
|
|
||||||
LastLoginAt: time.Now(),
|
|
||||||
}
|
|
||||||
if user.Nickname == "" {
|
|
||||||
user.Nickname = req.DisplayName
|
|
||||||
}
|
|
||||||
if user.Nickname == "" {
|
|
||||||
user.Nickname = req.Username
|
|
||||||
}
|
|
||||||
if err := user.SetPassword(req.Password); err != nil {
|
|
||||||
c.JSON(http.StatusOK, util.Err(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := user.RegisterUser(ctx, db.DB(ctx)); err != nil {
|
|
||||||
c.JSON(http.StatusOK, util.Err(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := setLoginSession(c, &user); err != nil {
|
|
||||||
c.JSON(http.StatusOK, util.Err(errSaveSessionFailed))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, util.OK(oauth.BuildBasicUserInfo(&user, false)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logout 用户退出登录
|
|
||||||
// @Summary 用户退出登录
|
|
||||||
// @Description 清除用户登录 Session,完成退出
|
|
||||||
// @Tags user
|
|
||||||
// @Produce json
|
|
||||||
// @Security SessionCookie
|
|
||||||
// @Success 200 {object} util.ResponseAny{data=string} "退出成功"
|
|
||||||
// @Failure 500 {object} util.ResponseAny "Session 清除失败"
|
|
||||||
// @Router /api/v1/user/logout [get]
|
|
||||||
func Logout(c *gin.Context) {
|
|
||||||
session := sessions.Default(c)
|
|
||||||
session.Options(util.GetSessionOptions(-1))
|
|
||||||
session.Clear()
|
|
||||||
if err := session.Save(); err != nil {
|
|
||||||
c.JSON(http.StatusOK, util.Err(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, util.OK(""))
|
|
||||||
}
|
|
||||||
|
|
||||||
type changePasswordRequest struct {
|
|
||||||
OldPassword string `json:"old_password"`
|
|
||||||
NewPassword string `json:"new_password"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChangePassword 修改用户密码
|
|
||||||
// @Summary 修改用户密码
|
|
||||||
// @Description 修改当前登录用户的密码。修改成功后,如果是首次明文登录的升级提示,则清除修改密码的提示状态。
|
|
||||||
// @Tags user
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param request body user.changePasswordRequest true "修改密码请求参数"
|
|
||||||
// @Success 200 {object} util.ResponseAny{data=string} "修改密码成功"
|
|
||||||
// @Failure 400 {object} util.ResponseAny "原密码错误或新密码不符合要求"
|
|
||||||
// @Failure 401 {object} util.ResponseAny "请先登录"
|
|
||||||
// @Router /api/v1/user/change-password [post]
|
|
||||||
func ChangePassword(c *gin.Context) {
|
|
||||||
var req changePasswordRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
req.OldPassword = strings.TrimSpace(req.OldPassword)
|
|
||||||
req.NewPassword = strings.TrimSpace(req.NewPassword)
|
|
||||||
|
|
||||||
if req.OldPassword == "" || req.NewPassword == "" {
|
|
||||||
c.JSON(http.StatusOK, util.Err(errInvalidParams))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(req.NewPassword) < minPasswordLength {
|
|
||||||
c.JSON(http.StatusOK, util.Err(errNewPasswordTooShort))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userObj, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
|
|
||||||
if userObj == nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, util.Err(errLoginRequired))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
var dbUser model.User
|
|
||||||
if err := db.DB(ctx).Where("id = ?", userObj.ID).First(&dbUser).Error; err != nil {
|
|
||||||
c.JSON(http.StatusOK, util.Err(errUserNotFound))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 校验旧密码
|
|
||||||
if !dbUser.CheckPassword(req.OldPassword) {
|
|
||||||
c.JSON(http.StatusOK, util.Err(errOldPasswordIncorrect))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加密并更新为新密码
|
|
||||||
if err := dbUser.SetEncryptedPassword(req.NewPassword); err != nil {
|
|
||||||
c.JSON(http.StatusOK, util.Err(errPasswordEncryptFailed))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.DB(ctx).Model(&dbUser).Update("password", dbUser.Password).Error; err != nil {
|
|
||||||
c.JSON(http.StatusOK, util.Err(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除 Session 中修改密码提示状态
|
|
||||||
session := sessions.Default(c)
|
|
||||||
session.Delete("need_change_password")
|
|
||||||
_ = session.Save()
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, util.OK("密码修改成功"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendEmailCode 发送邮箱验证码
|
|
||||||
// @Summary 发送邮箱验证码
|
|
||||||
// @Description 向指定邮箱发送验证码(用于注册场景)
|
|
||||||
// @Tags user
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param request body user.sendEmailCodeRequest true "发送验证码请求参数"
|
|
||||||
// @Success 200 {object} util.ResponseAny "发送成功"
|
|
||||||
// @Failure 400 {object} util.ResponseAny "参数错误"
|
|
||||||
// @Router /api/v1/user/send-email-code [post]
|
|
||||||
func SendEmailCode(c *gin.Context) {
|
|
||||||
var req sendEmailCodeRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Email = strings.TrimSpace(req.Email)
|
|
||||||
if req.Email == "" {
|
|
||||||
c.JSON(http.StatusOK, util.Err(errEmailRequired))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Scene != "register" {
|
|
||||||
c.JSON(http.StatusOK, util.Err(errUnsupportedEmailScene))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
// 1. 检查邮箱是否已被注册
|
|
||||||
var count int64
|
|
||||||
if err := db.DB(ctx).Model(&model.User{}).Where("email = ?", req.Email).Count(&count).Error; err != nil {
|
|
||||||
c.JSON(http.StatusOK, util.Err(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if count > 0 {
|
|
||||||
c.JSON(http.StatusOK, util.Err(errEmailAlreadyRegistered))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 校验 Redis 发送冷却时间
|
|
||||||
cooldownKey := getEmailCooldownKey(req.Email)
|
|
||||||
var temp string
|
|
||||||
err := db.GetJSON(ctx, cooldownKey, &temp)
|
|
||||||
if err == nil {
|
|
||||||
c.JSON(http.StatusOK, util.Err(errEmailCodeCooldown))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 发送验证码
|
|
||||||
if err := sendEmailVerificationCode(ctx, req.Email, "register", "register_email"); err != nil {
|
|
||||||
c.JSON(http.StatusOK, util.Err(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, util.OKNil())
|
|
||||||
}
|
|
||||||
|
|
||||||
type updateProfileRequest struct {
|
|
||||||
Nickname string `json:"nickname"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
AvatarURL string `json:"avatar_url"`
|
|
||||||
Bio string `json:"bio"`
|
|
||||||
Phone string `json:"phone"`
|
|
||||||
Gender string `json:"gender"`
|
|
||||||
Website string `json:"website"`
|
|
||||||
Location string `json:"location"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateProfile 修改当前登录用户的个人资料
|
|
||||||
// @Summary 修改当前登录用户的个人资料
|
|
||||||
// @Description 修改当前登录用户的昵称、邮箱、头像、简介、电话、性别、个人网站和所在地。
|
|
||||||
// @Tags user
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param request body user.updateProfileRequest true "更新请求参数"
|
|
||||||
// @Success 200 {object} util.ResponseAny{data=oauth.BasicUserInfo} "修改成功,返回更新后的用户信息"
|
|
||||||
// @Failure 400 {object} util.ResponseAny "邮箱已被占用或参数错误"
|
|
||||||
// @Failure 401 {object} util.ResponseAny "未登录"
|
|
||||||
// @Router /api/v1/user/profile [put]
|
|
||||||
func UpdateProfile(c *gin.Context) {
|
|
||||||
var req updateProfileRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userObj, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
|
|
||||||
if userObj == nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, util.Err(errLoginRequired))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
var dbUser model.User
|
|
||||||
if err := db.DB(ctx).Where("id = ?", userObj.ID).First(&dbUser).Error; err != nil {
|
|
||||||
c.JSON(http.StatusOK, util.Err(errUserNotFound))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 校验邮箱格式与唯一性
|
|
||||||
req.Email = strings.TrimSpace(req.Email)
|
|
||||||
if req.Email != "" && req.Email != dbUser.Email {
|
|
||||||
if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
|
|
||||||
c.JSON(http.StatusOK, util.Err(errEmailFormatInvalid))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var count int64
|
|
||||||
if err := db.DB(ctx).Model(&model.User{}).Where("email = ? AND id != ?", req.Email, dbUser.ID).Count(&count).Error; err != nil {
|
|
||||||
c.JSON(http.StatusOK, util.Err(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if count > 0 {
|
|
||||||
c.JSON(http.StatusOK, util.Err(errEmailAlreadyBound))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新字段
|
|
||||||
dbUser.Nickname = strings.TrimSpace(req.Nickname)
|
|
||||||
if dbUser.Nickname == "" {
|
|
||||||
dbUser.Nickname = dbUser.Username
|
|
||||||
}
|
|
||||||
dbUser.Email = req.Email
|
|
||||||
dbUser.AvatarURL = req.AvatarURL
|
|
||||||
dbUser.Bio = req.Bio
|
|
||||||
dbUser.Phone = strings.TrimSpace(req.Phone)
|
|
||||||
dbUser.Gender = strings.TrimSpace(req.Gender)
|
|
||||||
dbUser.Website = strings.TrimSpace(req.Website)
|
|
||||||
dbUser.Location = strings.TrimSpace(req.Location)
|
|
||||||
|
|
||||||
if err := db.DB(ctx).Save(&dbUser).Error; err != nil {
|
|
||||||
c.JSON(http.StatusOK, util.Err(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
session := sessions.Default(c)
|
|
||||||
needChange := session.Get("need_change_password") == true
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, util.OK(oauth.BuildBasicUserInfo(&dbUser, needChange)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateRegisterEmailVerification 校验注册时的邮箱验证码
|
|
||||||
func validateRegisterEmailVerification(ctx context.Context, req *registerRequest) error {
|
|
||||||
if !isEmailRegisterVerificationEnabled() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if req.Email == "" || req.Code == "" {
|
|
||||||
return errors.New(errEmailOrCodeRequired)
|
|
||||||
}
|
|
||||||
if !verifyEmailCode(ctx, req.Email, "register", req.Code) {
|
|
||||||
return errors.New(errEmailCodeInvalidOrExpired)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// completePendingOAuthBinding 完成登录后的 OAuth 待绑定绑定流程
|
|
||||||
func completePendingOAuthBinding(session sessions.Session, user *model.User) {
|
|
||||||
pendingSourceID := session.Get(oauth.PendingOAuthSourceIDKey)
|
|
||||||
pendingExternalID := session.Get(oauth.PendingOAuthExternalIDKey)
|
|
||||||
pendingExternalUsername := session.Get(oauth.PendingOAuthExternalUsernameKey)
|
|
||||||
pendingEmail := session.Get(oauth.PendingOAuthEmailKey)
|
|
||||||
|
|
||||||
if pendingSourceID == nil || pendingExternalID == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var sourceID uint64
|
|
||||||
switch v := pendingSourceID.(type) {
|
|
||||||
case uint64:
|
|
||||||
sourceID = v
|
|
||||||
case int:
|
|
||||||
sourceID = uint64(v)
|
|
||||||
case float64:
|
|
||||||
sourceID = uint64(v)
|
|
||||||
}
|
|
||||||
externalID, _ := pendingExternalID.(string)
|
|
||||||
externalUsername, _ := pendingExternalUsername.(string)
|
|
||||||
email, _ := pendingEmail.(string)
|
|
||||||
|
|
||||||
if sourceID != 0 && externalID != "" {
|
|
||||||
_ = model.BindExternalAccount(&model.ExternalAccount{
|
|
||||||
AuthSourceID: sourceID,
|
|
||||||
UserID: user.ID,
|
|
||||||
ExternalID: externalID,
|
|
||||||
ExternalUsername: externalUsername,
|
|
||||||
Email: email,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// 清除 pending 信息
|
|
||||||
session.Delete(oauth.PendingOAuthSourceIDKey)
|
|
||||||
session.Delete(oauth.PendingOAuthExternalIDKey)
|
|
||||||
session.Delete(oauth.PendingOAuthExternalUsernameKey)
|
|
||||||
session.Delete(oauth.PendingOAuthEmailKey)
|
|
||||||
_ = session.Save()
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2025 linux.do
|
Copyright 2026 Arctel.net
|
||||||
Modified by Arctel.net, 2026
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
@@ -16,3 +15,669 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
package user
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/common"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/db"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/task"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/util"
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type loginRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type registerRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sendEmailCodeRequest struct {
|
||||||
|
Email string `json:"email" binding:"required,email"`
|
||||||
|
Scene string `json:"scene" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEmailLoginVerificationEnabled() bool {
|
||||||
|
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyEmailLoginVerificationEnabled)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEmailRegisterVerificationEnabled() bool {
|
||||||
|
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyEmailRegisterVerificationEnabled)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSMTPConfigured(ctx context.Context) bool {
|
||||||
|
var sc model.SystemConfig
|
||||||
|
var host, port, username string
|
||||||
|
|
||||||
|
if err := sc.GetByKey(ctx, model.ConfigKeySMTPHost); err == nil {
|
||||||
|
host = sc.Value
|
||||||
|
}
|
||||||
|
if err := sc.GetByKey(ctx, model.ConfigKeySMTPPort); err == nil {
|
||||||
|
port = sc.Value
|
||||||
|
}
|
||||||
|
if err := sc.GetByKey(ctx, model.ConfigKeySMTPUsername); err == nil {
|
||||||
|
username = sc.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
return host != "" && port != "" && username != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateVerificationCode() string {
|
||||||
|
n, _ := rand.Int(rand.Reader, big.NewInt(verificationCodeRange))
|
||||||
|
return fmt.Sprintf("%06d", n.Int64()+verificationCodeOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEmailCodeKey(scene, email string) string {
|
||||||
|
return fmt.Sprintf("email_code:%s:%s", scene, email)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEmailCooldownKey(email string) string {
|
||||||
|
return fmt.Sprintf("email_code:cooldown:%s", email)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendEmailVerificationCode(ctx context.Context, email, scene, templateName string) error {
|
||||||
|
// 校验 SMTP 配置是否完整
|
||||||
|
if !isSMTPConfigured(ctx) {
|
||||||
|
return errors.New(errSMTPConfigIncomplete)
|
||||||
|
}
|
||||||
|
|
||||||
|
code := generateVerificationCode()
|
||||||
|
codeKey := getEmailCodeKey(scene, email)
|
||||||
|
cooldownKey := getEmailCooldownKey(email)
|
||||||
|
|
||||||
|
// 使用模板管理获取并渲染邮件标题和正文。模板缺失或渲染失败时不发送验证码。
|
||||||
|
emailSubject, emailBody, err := model.RenderTemplate(
|
||||||
|
ctx,
|
||||||
|
templateName,
|
||||||
|
map[string]any{"Code": code},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(errRenderEmailTemplateFailed, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存验证码,5分钟有效
|
||||||
|
if err := db.SetJSON(ctx, codeKey, code, emailCodeExpiry); err != nil {
|
||||||
|
return errors.New(errGenerateEmailCodeFailed)
|
||||||
|
}
|
||||||
|
// 存冷却,60秒有效
|
||||||
|
_ = db.SetJSON(ctx, cooldownKey, "1", emailCodeCooldown)
|
||||||
|
|
||||||
|
// 构建异步邮件发送任务
|
||||||
|
payload := SendEmailPayload{
|
||||||
|
To: email,
|
||||||
|
Subject: emailSubject,
|
||||||
|
Body: emailBody,
|
||||||
|
}
|
||||||
|
payloadBytes, _ := json.Marshal(payload)
|
||||||
|
_, err = task.DispatchTask(ctx, task.TaskTypeSendEmail, payloadBytes, "system")
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errDispatchEmailTaskFailed)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyEmailCode(ctx context.Context, email, scene, code string) bool {
|
||||||
|
codeKey := getEmailCodeKey(scene, email)
|
||||||
|
var storedCode string
|
||||||
|
if err := db.GetJSON(ctx, codeKey, &storedCode); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if storedCode != code {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 验证成功,删除验证码
|
||||||
|
_ = db.Redis.Del(ctx, db.PrefixedKey(codeKey)).Err()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPasswordLoginEnabled() bool {
|
||||||
|
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyPasswordLoginEnabled)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPasswordRegisterEnabled() bool {
|
||||||
|
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyPasswordRegisterEnabled)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRegistrationEnabled() bool {
|
||||||
|
enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyRegistrationEnabled)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func setLoginSession(c *gin.Context, user *model.User) error {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
session.Set(oauth.UserIDKey, user.ID)
|
||||||
|
session.Set(oauth.UserNameKey, user.Username)
|
||||||
|
if err := session.Save(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLoginEmailVerification 处理登录时的邮箱验证码校验流程
|
||||||
|
func handleLoginEmailVerification(ctx context.Context, c *gin.Context, req *loginRequest, user *model.User) error {
|
||||||
|
if user.Email == "" {
|
||||||
|
c.JSON(http.StatusOK, util.Err(errLoginEmailMissing))
|
||||||
|
return errors.New("handled")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Code == "" {
|
||||||
|
// 校验 Redis 发送冷却时间
|
||||||
|
cooldownKey := getEmailCooldownKey(user.Email)
|
||||||
|
var temp string
|
||||||
|
err := db.GetJSON(ctx, cooldownKey, &temp)
|
||||||
|
if err != nil {
|
||||||
|
// 没有冷却,触发验证码发送
|
||||||
|
if err := sendEmailVerificationCode(ctx, user.Email, "login", "login_email"); err != nil {
|
||||||
|
c.JSON(http.StatusOK, util.Err(err.Error()))
|
||||||
|
return errors.New("handled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 脱敏邮箱并返回错误,提示前端需要输入验证码
|
||||||
|
maskedEmail := util.MaskEmail(user.Email)
|
||||||
|
c.JSON(http.StatusOK, util.Err(errNeedEmailCodePrefix+maskedEmail))
|
||||||
|
return errors.New("handled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验验证码
|
||||||
|
if !verifyEmailCode(ctx, user.Email, "login", req.Code) {
|
||||||
|
c.JSON(http.StatusOK, util.Err(errEmailCodeInvalidOrExpired))
|
||||||
|
return errors.New("handled")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login 用户密码登录
|
||||||
|
// @Summary 用户密码登录
|
||||||
|
// @Description 使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。
|
||||||
|
// @Tags user
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body user.loginRequest true "登录请求参数"
|
||||||
|
// @Success 200 {object} util.ResponseAny{data=oauth.BasicUserInfo} "登录成功,返回用户信息"
|
||||||
|
// @Failure 400 {object} util.ResponseAny "用户名或密码错误、帐号已禁用等"
|
||||||
|
// @Failure 500 {object} util.ResponseAny "服务内部错误"
|
||||||
|
// @Router /api/v1/user/login [post]
|
||||||
|
func Login(c *gin.Context) {
|
||||||
|
if !isPasswordLoginEnabled() {
|
||||||
|
c.JSON(http.StatusOK, util.Err(errPasswordLoginDisabled))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req loginRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Username = strings.TrimSpace(req.Username)
|
||||||
|
if req.Username == "" || req.Password == "" {
|
||||||
|
c.JSON(http.StatusOK, util.Err(errInvalidParams))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var user model.User
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
if err := db.DB(ctx).Where("username = ?", req.Username).First(&user).Error; err != nil {
|
||||||
|
c.JSON(http.StatusOK, util.Err(errUsernameOrPasswordWrong))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !user.IsActive {
|
||||||
|
c.JSON(http.StatusOK, util.Err(common.BannedAccount))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判定是否是明文密码存储
|
||||||
|
isPlaintext := !user.IsPasswordEncrypted()
|
||||||
|
|
||||||
|
if !user.CheckPassword(req.Password) {
|
||||||
|
c.JSON(http.StatusOK, util.Err(errUsernameOrPasswordWrong))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isEmailLoginVerificationEnabled() {
|
||||||
|
if emailErr := handleLoginEmailVerification(ctx, c, &req, &user); emailErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session := sessions.Default(c)
|
||||||
|
needChangePassword := false
|
||||||
|
|
||||||
|
// 如果是以明文密码登录,在数据库中置换为加密密码
|
||||||
|
if isPlaintext {
|
||||||
|
if err := user.SetEncryptedPassword(req.Password); err == nil {
|
||||||
|
if err := db.DB(ctx).Model(&user).Update("password", user.Password).Error; err != nil {
|
||||||
|
c.JSON(http.StatusOK, util.Err(errPasswordUpgradeFailed))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
needChangePassword = true
|
||||||
|
session.Set("need_change_password", true)
|
||||||
|
_ = session.Save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user.LastLoginAt = time.Now()
|
||||||
|
if err := db.DB(ctx).Model(&user).Update("last_login_at", user.LastLoginAt).Error; err != nil {
|
||||||
|
c.JSON(http.StatusOK, util.Err(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := setLoginSession(c, &user); err != nil {
|
||||||
|
c.JSON(http.StatusOK, util.Err(errSaveSessionFailed))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有未完成 of OAuth/OIDC 绑定
|
||||||
|
completePendingOAuthBinding(session, &user)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, util.OK(oauth.BuildBasicUserInfo(&user, needChangePassword)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register 用户注册
|
||||||
|
// @Summary 用户注册
|
||||||
|
// @Description 使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。密码长度不能少于 8 位。
|
||||||
|
// @Tags user
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body user.registerRequest true "注册请求参数"
|
||||||
|
// @Success 200 {object} util.ResponseAny{data=oauth.BasicUserInfo} "注册并登录成功,返回用户信息"
|
||||||
|
// @Failure 400 {object} util.ResponseAny "参数错误、用户名已存在或注册已关闭"
|
||||||
|
// @Failure 500 {object} util.ResponseAny "服务内部错误"
|
||||||
|
// @Router /api/v1/user/register [post]
|
||||||
|
func Register(c *gin.Context) {
|
||||||
|
if !isRegistrationEnabled() || !isPasswordRegisterEnabled() {
|
||||||
|
c.JSON(http.StatusOK, util.Err(errRegistrationDisabled))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req registerRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Username = strings.TrimSpace(req.Username)
|
||||||
|
req.Password = strings.TrimSpace(req.Password)
|
||||||
|
req.Nickname = strings.TrimSpace(req.Nickname)
|
||||||
|
req.DisplayName = strings.TrimSpace(req.DisplayName)
|
||||||
|
req.Email = strings.TrimSpace(req.Email)
|
||||||
|
req.Code = strings.TrimSpace(req.Code)
|
||||||
|
|
||||||
|
if req.Username == "" || req.Password == "" {
|
||||||
|
c.JSON(http.StatusOK, util.Err(errInvalidParams))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Password) < minPasswordLength {
|
||||||
|
c.JSON(http.StatusOK, util.Err(errPasswordTooShort))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
// 邮箱注册验证校验
|
||||||
|
if err := validateRegisterEmailVerification(ctx, &req); err != nil {
|
||||||
|
c.JSON(http.StatusOK, util.Err(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := model.User{
|
||||||
|
Username: req.Username,
|
||||||
|
Nickname: req.Nickname,
|
||||||
|
Email: req.Email,
|
||||||
|
AvatarURL: "",
|
||||||
|
IsActive: true,
|
||||||
|
IsAdmin: false,
|
||||||
|
LastLoginAt: time.Now(),
|
||||||
|
}
|
||||||
|
if user.Nickname == "" {
|
||||||
|
user.Nickname = req.DisplayName
|
||||||
|
}
|
||||||
|
if user.Nickname == "" {
|
||||||
|
user.Nickname = req.Username
|
||||||
|
}
|
||||||
|
if err := user.SetPassword(req.Password); err != nil {
|
||||||
|
c.JSON(http.StatusOK, util.Err(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := user.RegisterUser(ctx, db.DB(ctx)); err != nil {
|
||||||
|
c.JSON(http.StatusOK, util.Err(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := setLoginSession(c, &user); err != nil {
|
||||||
|
c.JSON(http.StatusOK, util.Err(errSaveSessionFailed))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, util.OK(oauth.BuildBasicUserInfo(&user, false)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout 用户退出登录
|
||||||
|
// @Summary 用户退出登录
|
||||||
|
// @Description 清除用户登录 Session,完成退出
|
||||||
|
// @Tags user
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Success 200 {object} util.ResponseAny{data=string} "退出成功"
|
||||||
|
// @Failure 500 {object} util.ResponseAny "Session 清除失败"
|
||||||
|
// @Router /api/v1/user/logout [get]
|
||||||
|
func Logout(c *gin.Context) {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
session.Options(util.GetSessionOptions(-1))
|
||||||
|
session.Clear()
|
||||||
|
if err := session.Save(); err != nil {
|
||||||
|
c.JSON(http.StatusOK, util.Err(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, util.OK(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
type changePasswordRequest struct {
|
||||||
|
OldPassword string `json:"old_password"`
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword 修改用户密码
|
||||||
|
// @Summary 修改用户密码
|
||||||
|
// @Description 修改当前登录用户的密码。修改成功后,如果是首次明文登录的升级提示,则清除修改密码的提示状态。
|
||||||
|
// @Tags user
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body user.changePasswordRequest true "修改密码请求参数"
|
||||||
|
// @Success 200 {object} util.ResponseAny{data=string} "修改密码成功"
|
||||||
|
// @Failure 400 {object} util.ResponseAny "原密码错误或新密码不符合要求"
|
||||||
|
// @Failure 401 {object} util.ResponseAny "请先登录"
|
||||||
|
// @Router /api/v1/user/change-password [post]
|
||||||
|
func ChangePassword(c *gin.Context) {
|
||||||
|
var req changePasswordRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.OldPassword = strings.TrimSpace(req.OldPassword)
|
||||||
|
req.NewPassword = strings.TrimSpace(req.NewPassword)
|
||||||
|
|
||||||
|
if req.OldPassword == "" || req.NewPassword == "" {
|
||||||
|
c.JSON(http.StatusOK, util.Err(errInvalidParams))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.NewPassword) < minPasswordLength {
|
||||||
|
c.JSON(http.StatusOK, util.Err(errNewPasswordTooShort))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userObj, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
|
||||||
|
if userObj == nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, util.Err(errLoginRequired))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
var dbUser model.User
|
||||||
|
if err := db.DB(ctx).Where("id = ?", userObj.ID).First(&dbUser).Error; err != nil {
|
||||||
|
c.JSON(http.StatusOK, util.Err(errUserNotFound))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验旧密码
|
||||||
|
if !dbUser.CheckPassword(req.OldPassword) {
|
||||||
|
c.JSON(http.StatusOK, util.Err(errOldPasswordIncorrect))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加密并更新为新密码
|
||||||
|
if err := dbUser.SetEncryptedPassword(req.NewPassword); err != nil {
|
||||||
|
c.JSON(http.StatusOK, util.Err(errPasswordEncryptFailed))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.DB(ctx).Model(&dbUser).Update("password", dbUser.Password).Error; err != nil {
|
||||||
|
c.JSON(http.StatusOK, util.Err(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除 Session 中修改密码提示状态
|
||||||
|
session := sessions.Default(c)
|
||||||
|
session.Delete("need_change_password")
|
||||||
|
_ = session.Save()
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, util.OK("密码修改成功"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendEmailCode 发送邮箱验证码
|
||||||
|
// @Summary 发送邮箱验证码
|
||||||
|
// @Description 向指定邮箱发送验证码(用于注册场景)
|
||||||
|
// @Tags user
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body user.sendEmailCodeRequest true "发送验证码请求参数"
|
||||||
|
// @Success 200 {object} util.ResponseAny "发送成功"
|
||||||
|
// @Failure 400 {object} util.ResponseAny "参数错误"
|
||||||
|
// @Router /api/v1/user/send-email-code [post]
|
||||||
|
func SendEmailCode(c *gin.Context) {
|
||||||
|
var req sendEmailCodeRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Email = strings.TrimSpace(req.Email)
|
||||||
|
if req.Email == "" {
|
||||||
|
c.JSON(http.StatusOK, util.Err(errEmailRequired))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Scene != "register" {
|
||||||
|
c.JSON(http.StatusOK, util.Err(errUnsupportedEmailScene))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
// 1. 检查邮箱是否已被注册
|
||||||
|
var count int64
|
||||||
|
if err := db.DB(ctx).Model(&model.User{}).Where("email = ?", req.Email).Count(&count).Error; err != nil {
|
||||||
|
c.JSON(http.StatusOK, util.Err(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
c.JSON(http.StatusOK, util.Err(errEmailAlreadyRegistered))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 校验 Redis 发送冷却时间
|
||||||
|
cooldownKey := getEmailCooldownKey(req.Email)
|
||||||
|
var temp string
|
||||||
|
err := db.GetJSON(ctx, cooldownKey, &temp)
|
||||||
|
if err == nil {
|
||||||
|
c.JSON(http.StatusOK, util.Err(errEmailCodeCooldown))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 发送验证码
|
||||||
|
if err := sendEmailVerificationCode(ctx, req.Email, "register", "register_email"); err != nil {
|
||||||
|
c.JSON(http.StatusOK, util.Err(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, util.OKNil())
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateProfileRequest struct {
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
AvatarURL string `json:"avatar_url"`
|
||||||
|
Bio string `json:"bio"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
Gender string `json:"gender"`
|
||||||
|
Website string `json:"website"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProfile 修改当前登录用户的个人资料
|
||||||
|
// @Summary 修改当前登录用户的个人资料
|
||||||
|
// @Description 修改当前登录用户的昵称、邮箱、头像、简介、电话、性别、个人网站和所在地。
|
||||||
|
// @Tags user
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body user.updateProfileRequest true "更新请求参数"
|
||||||
|
// @Success 200 {object} util.ResponseAny{data=oauth.BasicUserInfo} "修改成功,返回更新后的用户信息"
|
||||||
|
// @Failure 400 {object} util.ResponseAny "邮箱已被占用或参数错误"
|
||||||
|
// @Failure 401 {object} util.ResponseAny "未登录"
|
||||||
|
// @Router /api/v1/user/profile [put]
|
||||||
|
func UpdateProfile(c *gin.Context) {
|
||||||
|
var req updateProfileRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userObj, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
|
||||||
|
if userObj == nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, util.Err(errLoginRequired))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
var dbUser model.User
|
||||||
|
if err := db.DB(ctx).Where("id = ?", userObj.ID).First(&dbUser).Error; err != nil {
|
||||||
|
c.JSON(http.StatusOK, util.Err(errUserNotFound))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验邮箱格式与唯一性
|
||||||
|
req.Email = strings.TrimSpace(req.Email)
|
||||||
|
if req.Email != "" && req.Email != dbUser.Email {
|
||||||
|
if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
|
||||||
|
c.JSON(http.StatusOK, util.Err(errEmailFormatInvalid))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
if err := db.DB(ctx).Model(&model.User{}).Where("email = ? AND id != ?", req.Email, dbUser.ID).Count(&count).Error; err != nil {
|
||||||
|
c.JSON(http.StatusOK, util.Err(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
c.JSON(http.StatusOK, util.Err(errEmailAlreadyBound))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新字段
|
||||||
|
dbUser.Nickname = strings.TrimSpace(req.Nickname)
|
||||||
|
if dbUser.Nickname == "" {
|
||||||
|
dbUser.Nickname = dbUser.Username
|
||||||
|
}
|
||||||
|
dbUser.Email = req.Email
|
||||||
|
dbUser.AvatarURL = req.AvatarURL
|
||||||
|
dbUser.Bio = req.Bio
|
||||||
|
dbUser.Phone = strings.TrimSpace(req.Phone)
|
||||||
|
dbUser.Gender = strings.TrimSpace(req.Gender)
|
||||||
|
dbUser.Website = strings.TrimSpace(req.Website)
|
||||||
|
dbUser.Location = strings.TrimSpace(req.Location)
|
||||||
|
|
||||||
|
if err := db.DB(ctx).Save(&dbUser).Error; err != nil {
|
||||||
|
c.JSON(http.StatusOK, util.Err(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session := sessions.Default(c)
|
||||||
|
needChange := session.Get("need_change_password") == true
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, util.OK(oauth.BuildBasicUserInfo(&dbUser, needChange)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateRegisterEmailVerification 校验注册时的邮箱验证码
|
||||||
|
func validateRegisterEmailVerification(ctx context.Context, req *registerRequest) error {
|
||||||
|
if !isEmailRegisterVerificationEnabled() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if req.Email == "" || req.Code == "" {
|
||||||
|
return errors.New(errEmailOrCodeRequired)
|
||||||
|
}
|
||||||
|
if !verifyEmailCode(ctx, req.Email, "register", req.Code) {
|
||||||
|
return errors.New(errEmailCodeInvalidOrExpired)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// completePendingOAuthBinding 完成登录后的 OAuth 待绑定绑定流程
|
||||||
|
func completePendingOAuthBinding(session sessions.Session, user *model.User) {
|
||||||
|
pendingSourceID := session.Get(oauth.PendingOAuthSourceIDKey)
|
||||||
|
pendingExternalID := session.Get(oauth.PendingOAuthExternalIDKey)
|
||||||
|
pendingExternalUsername := session.Get(oauth.PendingOAuthExternalUsernameKey)
|
||||||
|
pendingEmail := session.Get(oauth.PendingOAuthEmailKey)
|
||||||
|
|
||||||
|
if pendingSourceID == nil || pendingExternalID == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceID uint64
|
||||||
|
switch v := pendingSourceID.(type) {
|
||||||
|
case uint64:
|
||||||
|
sourceID = v
|
||||||
|
case int:
|
||||||
|
sourceID = uint64(v)
|
||||||
|
case float64:
|
||||||
|
sourceID = uint64(v)
|
||||||
|
}
|
||||||
|
externalID, _ := pendingExternalID.(string)
|
||||||
|
externalUsername, _ := pendingExternalUsername.(string)
|
||||||
|
email, _ := pendingEmail.(string)
|
||||||
|
|
||||||
|
if sourceID != 0 && externalID != "" {
|
||||||
|
_ = model.BindExternalAccount(&model.ExternalAccount{
|
||||||
|
AuthSourceID: sourceID,
|
||||||
|
UserID: user.ID,
|
||||||
|
ExternalID: externalID,
|
||||||
|
ExternalUsername: externalUsername,
|
||||||
|
Email: email,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 清除 pending 信息
|
||||||
|
session.Delete(oauth.PendingOAuthSourceIDKey)
|
||||||
|
session.Delete(oauth.PendingOAuthExternalIDKey)
|
||||||
|
session.Delete(oauth.PendingOAuthExternalUsernameKey)
|
||||||
|
session.Delete(oauth.PendingOAuthEmailKey)
|
||||||
|
_ = session.Save()
|
||||||
|
}
|
||||||
|
|||||||
+2
-1
@@ -15,6 +15,7 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Package cmd 提供 CLI 命令入口
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -30,7 +31,7 @@ import (
|
|||||||
var allCmd = &cobra.Command{
|
var allCmd = &cobra.Command{
|
||||||
Use: "all",
|
Use: "all",
|
||||||
Short: "以融合模式同时启动 API、Worker 和 Scheduler",
|
Short: "以融合模式同时启动 API、Worker 和 Scheduler",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(_ *cobra.Command, _ []string) {
|
||||||
log.Println("[All] 融合模式启动")
|
log.Println("[All] 融合模式启动")
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ import (
|
|||||||
var apiCmd = &cobra.Command{
|
var apiCmd = &cobra.Command{
|
||||||
Use: "api",
|
Use: "api",
|
||||||
Short: "wavelet API",
|
Short: "wavelet API",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(_ *cobra.Command, _ []string) {
|
||||||
router.Serve()
|
router.Serve()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ import (
|
|||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "wavelet",
|
Use: "wavelet",
|
||||||
PreRun: func(cmd *cobra.Command, args []string) {
|
PreRun: func(_ *cobra.Command, _ []string) {
|
||||||
migrator.Migrate()
|
migrator.Migrate()
|
||||||
},
|
},
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(_ *cobra.Command, args []string) {
|
||||||
// 无参数时默认以融合模式启动所有服务
|
// 无参数时默认以融合模式启动所有服务
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
allCmd.Run(allCmd, args)
|
allCmd.Run(allCmd, args)
|
||||||
@@ -55,6 +55,7 @@ func init() {
|
|||||||
rootCmd.CompletionOptions.DisableDefaultCmd = true
|
rootCmd.CompletionOptions.DisableDefaultCmd = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Execute 执行根命令
|
||||||
func Execute() {
|
func Execute() {
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
log.Fatalf("[CMD] execute failed; %s\n", err)
|
log.Fatalf("[CMD] execute failed; %s\n", err)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import (
|
|||||||
var schedulerCmd = &cobra.Command{
|
var schedulerCmd = &cobra.Command{
|
||||||
Use: "scheduler",
|
Use: "scheduler",
|
||||||
Short: "wavelet Scheduler",
|
Short: "wavelet Scheduler",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(_ *cobra.Command, _ []string) {
|
||||||
log.Println("[Scheduler] 启动定时任务调度服务")
|
log.Println("[Scheduler] 启动定时任务调度服务")
|
||||||
if err := scheduler.StartScheduler(); err != nil {
|
if err := scheduler.StartScheduler(); err != nil {
|
||||||
log.Fatalf("[调度器] 启动失败: %v", err)
|
log.Fatalf("[调度器] 启动失败: %v", err)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import (
|
|||||||
var workerCmd = &cobra.Command{
|
var workerCmd = &cobra.Command{
|
||||||
Use: "worker",
|
Use: "worker",
|
||||||
Short: "wavelet Worker",
|
Short: "wavelet Worker",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(_ *cobra.Command, _ []string) {
|
||||||
log.Println("[Worker] 启动任务处理服务")
|
log.Println("[Worker] 启动任务处理服务")
|
||||||
if err := worker.StartWorker(); err != nil {
|
if err := worker.StartWorker(); err != nil {
|
||||||
log.Fatalf("[工作器] 启动失败: %v", err)
|
log.Fatalf("[工作器] 启动失败: %v", err)
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ type appConfig struct {
|
|||||||
SessionSecret string `mapstructure:"session_secret"`
|
SessionSecret string `mapstructure:"session_secret"`
|
||||||
SessionDomain string `mapstructure:"session_domain"`
|
SessionDomain string `mapstructure:"session_domain"`
|
||||||
SessionAge int `mapstructure:"session_age"`
|
SessionAge int `mapstructure:"session_age"`
|
||||||
SessionHttpOnly bool `mapstructure:"session_http_only"`
|
SessionHTTPOnly bool `mapstructure:"session_http_only"`
|
||||||
SessionSecure bool `mapstructure:"session_secure"`
|
SessionSecure bool `mapstructure:"session_secure"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Package migrator 提供数据库自动迁移功能
|
||||||
package migrator
|
package migrator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -37,6 +38,7 @@ func dbType() string {
|
|||||||
return "PostgreSQL"
|
return "PostgreSQL"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migrate 执行数据库自动迁移,初始化系统配置、默认管理员和内置模板
|
||||||
func Migrate() {
|
func Migrate() {
|
||||||
if err := db.DB(context.Background()).AutoMigrate(
|
if err := db.DB(context.Background()).AutoMigrate(
|
||||||
&model.User{},
|
&model.User{},
|
||||||
@@ -276,7 +278,7 @@ func initDefaultAdmin() {
|
|||||||
Username: "admin",
|
Username: "admin",
|
||||||
Password: "12345678", // 密码使用明文存储
|
Password: "12345678", // 密码使用明文存储
|
||||||
Nickname: "Administrator",
|
Nickname: "Administrator",
|
||||||
AvatarUrl: "",
|
AvatarURL: "",
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
IsAdmin: true,
|
IsAdmin: true,
|
||||||
LastLoginAt: time.Now(),
|
LastLoginAt: time.Now(),
|
||||||
|
|||||||
+13
-7
@@ -31,20 +31,20 @@ import (
|
|||||||
|
|
||||||
// OAuthUserInfo 用户信息结构(同时支持 OIDC ID Token claims 和 UserEndpoint 响应)
|
// OAuthUserInfo 用户信息结构(同时支持 OIDC ID Token claims 和 UserEndpoint 响应)
|
||||||
type OAuthUserInfo struct {
|
type OAuthUserInfo struct {
|
||||||
Id uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
Sub string `json:"sub"`
|
Sub string `json:"sub"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
PreferredUsername string `json:"preferred_username"`
|
PreferredUsername string `json:"preferred_username"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Active bool `json:"active"`
|
Active bool `json:"active"`
|
||||||
AvatarUrl string `json:"avatar_url"`
|
AvatarURL string `json:"avatar_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetID 获取用户 ID
|
// GetID 获取用户 ID
|
||||||
func (u *OAuthUserInfo) GetID() uint64 {
|
func (u *OAuthUserInfo) GetID() uint64 {
|
||||||
if u.Id != 0 {
|
if u.ID != 0 {
|
||||||
return u.Id
|
return u.ID
|
||||||
}
|
}
|
||||||
// 从 sub 解析(OIDC 格式)
|
// 从 sub 解析(OIDC 格式)
|
||||||
if u.Sub != "" {
|
if u.Sub != "" {
|
||||||
@@ -55,13 +55,14 @@ func (u *OAuthUserInfo) GetID() uint64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User 用户表实体
|
||||||
type User struct {
|
type User struct {
|
||||||
ID uint64 `json:"id" gorm:"primaryKey"`
|
ID uint64 `json:"id" gorm:"primaryKey"`
|
||||||
Username string `json:"username" gorm:"size:64;uniqueIndex"`
|
Username string `json:"username" gorm:"size:64;uniqueIndex"`
|
||||||
Password string `json:"password,omitempty" gorm:"size:255"`
|
Password string `json:"password,omitempty" gorm:"size:255"`
|
||||||
Nickname string `json:"nickname" gorm:"size:255"`
|
Nickname string `json:"nickname" gorm:"size:255"`
|
||||||
Email string `json:"email" gorm:"size:255;index"`
|
Email string `json:"email" gorm:"size:255;index"`
|
||||||
AvatarUrl string `json:"avatar_url" gorm:"size:255"`
|
AvatarURL string `json:"avatar_url" gorm:"size:255"`
|
||||||
IsActive bool `json:"is_active" gorm:"default:true;index"`
|
IsActive bool `json:"is_active" gorm:"default:true;index"`
|
||||||
IsAdmin bool `json:"is_admin" gorm:"default:false"`
|
IsAdmin bool `json:"is_admin" gorm:"default:false"`
|
||||||
Bio string `json:"bio" gorm:"size:500"`
|
Bio string `json:"bio" gorm:"size:500"`
|
||||||
@@ -74,11 +75,13 @@ type User struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
|
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPassword 设置明文密码
|
||||||
func (u *User) SetPassword(password string) error {
|
func (u *User) SetPassword(password string) error {
|
||||||
u.Password = password
|
u.Password = password
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetEncryptedPassword 设置加密密码
|
||||||
func (u *User) SetEncryptedPassword(password string) error {
|
func (u *User) SetEncryptedPassword(password string) error {
|
||||||
if password == "" {
|
if password == "" {
|
||||||
u.Password = ""
|
u.Password = ""
|
||||||
@@ -92,10 +95,12 @@ func (u *User) SetEncryptedPassword(password string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsPasswordEncrypted 检查密码是否已加密
|
||||||
func (u *User) IsPasswordEncrypted() bool {
|
func (u *User) IsPasswordEncrypted() bool {
|
||||||
return strings.HasPrefix(u.Password, "$2a$") || strings.HasPrefix(u.Password, "$2b$") || strings.HasPrefix(u.Password, "$2y$")
|
return strings.HasPrefix(u.Password, "$2a$") || strings.HasPrefix(u.Password, "$2b$") || strings.HasPrefix(u.Password, "$2y$")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckPassword 验证密码是否匹配
|
||||||
func (u *User) CheckPassword(password string) bool {
|
func (u *User) CheckPassword(password string) bool {
|
||||||
if u.Password == "" || password == "" {
|
if u.Password == "" || password == "" {
|
||||||
return false
|
return false
|
||||||
@@ -106,6 +111,7 @@ func (u *User) CheckPassword(password string) bool {
|
|||||||
return u.Password == password
|
return u.Password == password
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetByID 根据 ID 查询用户
|
||||||
func (u *User) GetByID(tx *gorm.DB, id uint64) error {
|
func (u *User) GetByID(tx *gorm.DB, id uint64) error {
|
||||||
if err := tx.Where("id = ?", id).First(u).Error; err != nil {
|
if err := tx.Where("id = ?", id).First(u).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -118,7 +124,7 @@ func (u *User) UpdateFromOAuthInfo(oauthInfo *OAuthUserInfo) {
|
|||||||
u.Username = oauthInfo.Username
|
u.Username = oauthInfo.Username
|
||||||
u.Nickname = oauthInfo.Name
|
u.Nickname = oauthInfo.Name
|
||||||
u.Email = oauthInfo.Email
|
u.Email = oauthInfo.Email
|
||||||
u.AvatarUrl = oauthInfo.AvatarUrl
|
u.AvatarURL = oauthInfo.AvatarURL
|
||||||
u.IsActive = oauthInfo.Active
|
u.IsActive = oauthInfo.Active
|
||||||
u.LastLoginAt = time.Now()
|
u.LastLoginAt = time.Now()
|
||||||
}
|
}
|
||||||
@@ -144,7 +150,7 @@ func (u *User) CreateUser(ctx context.Context, tx *gorm.DB, oauthInfo *OAuthUser
|
|||||||
Username: oauthInfo.Username,
|
Username: oauthInfo.Username,
|
||||||
Nickname: oauthInfo.Name,
|
Nickname: oauthInfo.Name,
|
||||||
Email: oauthInfo.Email,
|
Email: oauthInfo.Email,
|
||||||
AvatarUrl: oauthInfo.AvatarUrl,
|
AvatarURL: oauthInfo.AvatarURL,
|
||||||
IsActive: oauthInfo.Active,
|
IsActive: oauthInfo.Active,
|
||||||
LastLoginAt: now,
|
LastLoginAt: now,
|
||||||
IsAdmin: false,
|
IsAdmin: false,
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Package router 提供 HTTP 路由注册与服务启动
|
||||||
package router
|
package router
|
||||||
|
|
||||||
import "github.com/gin-gonic/gin"
|
import "github.com/gin-gonic/gin"
|
||||||
|
|
||||||
func registerFrontend(r *gin.Engine) {
|
func registerFrontend(_ *gin.Engine) {
|
||||||
// No-op when not embedding frontend
|
// No-op when not embedding frontend
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import (
|
|||||||
"github.com/Rain-kl/Wavelet/internal/util"
|
"github.com/Rain-kl/Wavelet/internal/util"
|
||||||
capUtil "github.com/Rain-kl/Wavelet/internal/util/cap"
|
capUtil "github.com/Rain-kl/Wavelet/internal/util/cap"
|
||||||
|
|
||||||
|
// Swagger 文档生成
|
||||||
_ "github.com/Rain-kl/Wavelet/docs"
|
_ "github.com/Rain-kl/Wavelet/docs"
|
||||||
"github.com/Rain-kl/Wavelet/internal/apps/admin/system_config"
|
"github.com/Rain-kl/Wavelet/internal/apps/admin/system_config"
|
||||||
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
||||||
@@ -58,6 +59,7 @@ import (
|
|||||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
|
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Serve 启动 HTTP API 服务
|
||||||
func Serve() {
|
func Serve() {
|
||||||
// 运行模式
|
// 运行模式
|
||||||
if config.Config.App.IsProduction() {
|
if config.Config.App.IsProduction() {
|
||||||
@@ -267,13 +269,15 @@ func Serve() {
|
|||||||
<-quit
|
<-quit
|
||||||
|
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Duration(config.Config.App.GracefulShutdownTimeout)*time.Second)
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Duration(config.Config.App.GracefulShutdownTimeout)*time.Second)
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
otel_trace.Shutdown(shutdownCtx)
|
otel_trace.Shutdown(shutdownCtx)
|
||||||
|
|
||||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||||
log.Fatalf("[API] server forced to shutdown: %v\n", err)
|
log.Printf("[API] server forced to shutdown: %v\n", err)
|
||||||
|
cancel()
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
cancel()
|
||||||
|
|
||||||
log.Println("[API] server exited")
|
log.Println("[API] server exited")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ func NewMemoryStore(cleanupInterval time.Duration) *MemoryStore {
|
|||||||
return store
|
return store
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MemoryStore) Get(ctx context.Context, key string) (string, bool, error) {
|
// Get 从 MemoryStore 获取指定 key 的值
|
||||||
|
func (s *MemoryStore) Get(_ context.Context, key string) (string, bool, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
return s.getLocked(key)
|
return s.getLocked(key)
|
||||||
@@ -79,7 +80,8 @@ func (s *MemoryStore) getLocked(key string) (string, bool, error) {
|
|||||||
return item.value, true, nil
|
return item.value, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MemoryStore) Set(ctx context.Context, key string, val string, ttl time.Duration) error {
|
// Set 向 MemoryStore 写入指定 key 的值
|
||||||
|
func (s *MemoryStore) Set(_ context.Context, key string, val string, ttl time.Duration) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
s.items[key] = memoryItem{
|
s.items[key] = memoryItem{
|
||||||
@@ -89,7 +91,8 @@ func (s *MemoryStore) Set(ctx context.Context, key string, val string, ttl time.
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MemoryStore) Delete(ctx context.Context, key string) error {
|
// Delete 从 MemoryStore 删除指定 key
|
||||||
|
func (s *MemoryStore) Delete(_ context.Context, key string) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
delete(s.items, key)
|
delete(s.items, key)
|
||||||
@@ -98,7 +101,7 @@ func (s *MemoryStore) Delete(ctx context.Context, key string) error {
|
|||||||
|
|
||||||
// SetNX atomically sets key only when it is absent (or expired).
|
// SetNX atomically sets key only when it is absent (or expired).
|
||||||
// Returns true if the key was written by this call.
|
// Returns true if the key was written by this call.
|
||||||
func (s *MemoryStore) SetNX(ctx context.Context, key string, val string, ttl time.Duration) (bool, error) {
|
func (s *MemoryStore) SetNX(_ context.Context, key string, val string, ttl time.Duration) (bool, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
@@ -114,7 +117,7 @@ func (s *MemoryStore) SetNX(ctx context.Context, key string, val string, ttl tim
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetAndDelete atomically retrieves and removes key in one critical section.
|
// GetAndDelete atomically retrieves and removes key in one critical section.
|
||||||
func (s *MemoryStore) GetAndDelete(ctx context.Context, key string) (string, bool, error) {
|
func (s *MemoryStore) GetAndDelete(_ context.Context, key string) (string, bool, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
@@ -156,6 +159,7 @@ func NewRedisStore(client redis.UniversalClient) *RedisStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get 从 RedisStore 获取指定 key 的值
|
||||||
func (s *RedisStore) Get(ctx context.Context, key string) (string, bool, error) {
|
func (s *RedisStore) Get(ctx context.Context, key string) (string, bool, error) {
|
||||||
val, err := s.client.Get(ctx, key).Result()
|
val, err := s.client.Get(ctx, key).Result()
|
||||||
if err == redis.Nil {
|
if err == redis.Nil {
|
||||||
@@ -167,10 +171,12 @@ func (s *RedisStore) Get(ctx context.Context, key string) (string, bool, error)
|
|||||||
return val, true, nil
|
return val, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set 向 RedisStore 写入指定 key 的值
|
||||||
func (s *RedisStore) Set(ctx context.Context, key string, val string, ttl time.Duration) error {
|
func (s *RedisStore) Set(ctx context.Context, key string, val string, ttl time.Duration) error {
|
||||||
return s.client.Set(ctx, key, val, ttl).Err()
|
return s.client.Set(ctx, key, val, ttl).Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete 从 RedisStore 删除指定 key
|
||||||
func (s *RedisStore) Delete(ctx context.Context, key string) error {
|
func (s *RedisStore) Delete(ctx context.Context, key string) error {
|
||||||
return s.client.Del(ctx, key).Err()
|
return s.client.Del(ctx, key).Err()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,13 @@ import (
|
|||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// GetSessionOptions 根据配置构建 Session 选项
|
||||||
func GetSessionOptions(maxAge int) sessions.Options {
|
func GetSessionOptions(maxAge int) sessions.Options {
|
||||||
return sessions.Options{
|
return sessions.Options{
|
||||||
Path: "/",
|
Path: "/",
|
||||||
Domain: config.Config.App.SessionDomain,
|
Domain: config.Config.App.SessionDomain,
|
||||||
MaxAge: maxAge,
|
MaxAge: maxAge,
|
||||||
HttpOnly: config.Config.App.SessionHttpOnly,
|
HttpOnly: config.Config.App.SessionHTTPOnly,
|
||||||
Secure: config.Config.App.SessionSecure,
|
Secure: config.Config.App.SessionSecure,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user