mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +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 组件样式规范
|
||||
|
||||
**基础组件必须遵循系统的色彩主题系统。** 所有基于 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 的基础组件(Button、Dialog、Input 等)应使用组件内置 of `variant` 属性来控制样式,禁止通过 className 手写颜色、背景、阴影等样式。
|
||||
|
||||
> **原则**:组件的视觉表现由 shadcn/ui 的 variant 系统和全局 CSS 变量统一控制,保持应用内所有页面风格一致。如现有 variant 无法满足需求,应扩展 shadcn/ui 组件的 variant 定义,而非在业务代码中硬编码颜色值。
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ build-test:
|
||||
@PIDS=""; \
|
||||
STATUS=0; \
|
||||
( 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 \
|
||||
wait $$PID || STATUS=1; \
|
||||
done; \
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
// getUpgrader 返回 WebSocket 升级器
|
||||
func getUpgrader() *websocket.Upgrader {
|
||||
return &websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
CheckOrigin: func(_ *http.Request) bool {
|
||||
return true // CORS 由 Gin 中间件处理
|
||||
},
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// Package config 提供公开配置查询接口
|
||||
package config
|
||||
|
||||
import (
|
||||
|
||||
@@ -40,6 +40,7 @@ type loginRequiredAuditLog struct {
|
||||
Referer string `json:"referer"`
|
||||
}
|
||||
|
||||
// LoginRequired 返回登录鉴权中间件,校验 Access Token 或 Session
|
||||
func LoginRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// init trace
|
||||
@@ -73,14 +74,14 @@ func LoginRequired() gin.HandlerFunc {
|
||||
|
||||
if !authenticated {
|
||||
// load user from session
|
||||
userId := GetUserIDFromContext(c)
|
||||
if userId <= 0 {
|
||||
userID := GetUserIDFromContext(c)
|
||||
if userID <= 0 {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error_msg": common.UnAuthorized, "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
// 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 {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error_msg": common.UnAuthorized, "data": nil})
|
||||
return
|
||||
|
||||
@@ -26,12 +26,13 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// BasicUserInfo 用户基本信息结构体
|
||||
type BasicUserInfo struct {
|
||||
ID uint64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Email string `json:"email"`
|
||||
AvatarUrl string `json:"avatar_url"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
NeedChangePassword bool `json:"need_change_password"`
|
||||
Bio string `json:"bio"`
|
||||
@@ -41,13 +42,14 @@ type BasicUserInfo struct {
|
||||
Location string `json:"location"`
|
||||
}
|
||||
|
||||
// BuildBasicUserInfo 将 User 模型转换为 BasicUserInfo
|
||||
func BuildBasicUserInfo(user *model.User, needChange bool) BasicUserInfo {
|
||||
return BasicUserInfo{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
Email: user.Email,
|
||||
AvatarUrl: user.AvatarUrl,
|
||||
AvatarURL: user.AvatarURL,
|
||||
IsAdmin: user.IsAdmin,
|
||||
NeedChangePassword: needChange,
|
||||
Bio: user.Bio,
|
||||
|
||||
+113
-77
@@ -49,12 +49,14 @@ type AuthSourceView struct {
|
||||
ClientSecretConfigured bool `json:"client_secret_configured"`
|
||||
}
|
||||
|
||||
// OAuthAuthorizeResponse 授权 URL 响应
|
||||
// AuthorizeResponse 授权 URL 响应
|
||||
//nolint:revive // OAuth 前缀保持包内语义清晰
|
||||
type OAuthAuthorizeResponse struct {
|
||||
AuthorizeURL string `json:"authorize_url"`
|
||||
}
|
||||
|
||||
// OAuthCallbackResult 回调处理结果
|
||||
// CallbackResult 回调处理结果
|
||||
//nolint:revive // OAuth 前缀保持包内语义清晰
|
||||
type OAuthCallbackResult struct {
|
||||
Status string `json:"status"`
|
||||
User *BasicUserInfo `json:"user,omitempty"`
|
||||
@@ -66,6 +68,7 @@ type CallbackRequest struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
|
||||
// GetUserIDFromSession 从 Session 中提取用户 ID
|
||||
func GetUserIDFromSession(s sessions.Session) uint64 {
|
||||
userID, ok := s.Get(UserIDKey).(uint64)
|
||||
if !ok {
|
||||
@@ -74,6 +77,7 @@ func GetUserIDFromSession(s sessions.Session) uint64 {
|
||||
return userID
|
||||
}
|
||||
|
||||
// GetUserIDFromContext 从 Gin Context 的 Session 中提取用户 ID
|
||||
func GetUserIDFromContext(c *gin.Context) uint64 {
|
||||
session := sessions.Default(c)
|
||||
return GetUserIDFromSession(session)
|
||||
@@ -210,17 +214,8 @@ func buildOAuthUserInfo(ctx context.Context, source *model.AuthSource, code stri
|
||||
|
||||
userInfo := &model.OAuthUserInfo{Active: true}
|
||||
if verifier != nil {
|
||||
if rawIDToken, ok := token.Extra("id_token").(string); ok {
|
||||
idToken, verifyErr := verifier.Verify(ctx, rawIDToken)
|
||||
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
|
||||
}
|
||||
if verifyErr := verifyIDToken(ctx, verifier, token, nonce, userInfo); verifyErr != nil {
|
||||
return nil, verifyErr
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,12 +235,31 @@ func buildOAuthUserInfo(ctx context.Context, source *model.AuthSource, code stri
|
||||
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 {
|
||||
userInfo.Username = strings.TrimSpace(userInfo.Username)
|
||||
userInfo.PreferredUsername = strings.TrimSpace(userInfo.PreferredUsername)
|
||||
userInfo.Email = strings.TrimSpace(userInfo.Email)
|
||||
userInfo.Name = strings.TrimSpace(userInfo.Name)
|
||||
userInfo.AvatarUrl = strings.TrimSpace(userInfo.AvatarUrl)
|
||||
userInfo.AvatarURL = strings.TrimSpace(userInfo.AvatarURL)
|
||||
|
||||
if userInfo.Username == "" && userInfo.PreferredUsername != "" {
|
||||
userInfo.Username = userInfo.PreferredUsername
|
||||
@@ -446,33 +460,45 @@ func Callback(c *gin.Context) {
|
||||
userInfo.Sub = userInfo.Username
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if payload.Purpose == OAuthPurposeBind {
|
||||
userID := GetUserIDFromContext(c)
|
||||
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")))
|
||||
handleCallbackBind(ctx, c, source, userInfo)
|
||||
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)
|
||||
switch {
|
||||
case err == nil:
|
||||
@@ -481,47 +507,11 @@ func Callback(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
// 检查系统是否允许注册
|
||||
registrationEnabled, regErr := model.GetBoolByKey(ctx, model.ConfigKeyRegistrationEnabled)
|
||||
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()))
|
||||
newUser, ok := handleCallbackRegister(ctx, c, source, userInfo)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user = newUser
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
@@ -533,10 +523,56 @@ func Callback(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
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 获取当前用户的外部帐号绑定列表
|
||||
// @Summary 获取外部帐号列表
|
||||
// @Description 返回当前登录用户已绑定的所有外部 OAuth 帐号信息,需要登录
|
||||
|
||||
@@ -33,9 +33,9 @@ import (
|
||||
type CleanupUnusedUploadsHandler struct{}
|
||||
|
||||
// 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个文件
|
||||
var lastID uint64 = 0
|
||||
var lastID uint64
|
||||
var totalProcessed int
|
||||
var totalDeleted int
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// Package user 提供用户认证与帐户管理功能
|
||||
package user
|
||||
|
||||
import (
|
||||
|
||||
@@ -16,3 +16,13 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
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
|
||||
Modified by Arctel.net, 2026
|
||||
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.
|
||||
@@ -16,3 +15,669 @@ 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"
|
||||
)
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
// Package cmd 提供 CLI 命令入口
|
||||
package cmd
|
||||
|
||||
import (
|
||||
@@ -30,7 +31,7 @@ import (
|
||||
var allCmd = &cobra.Command{
|
||||
Use: "all",
|
||||
Short: "以融合模式同时启动 API、Worker 和 Scheduler",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
log.Println("[All] 融合模式启动")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ import (
|
||||
var apiCmd = &cobra.Command{
|
||||
Use: "api",
|
||||
Short: "wavelet API",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
router.Serve()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -26,10 +26,10 @@ import (
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "wavelet",
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
PreRun: func(_ *cobra.Command, _ []string) {
|
||||
migrator.Migrate()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, args []string) {
|
||||
// 无参数时默认以融合模式启动所有服务
|
||||
if len(args) == 0 {
|
||||
allCmd.Run(allCmd, args)
|
||||
@@ -55,6 +55,7 @@ func init() {
|
||||
rootCmd.CompletionOptions.DisableDefaultCmd = true
|
||||
}
|
||||
|
||||
// Execute 执行根命令
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
log.Fatalf("[CMD] execute failed; %s\n", err)
|
||||
|
||||
@@ -28,7 +28,7 @@ import (
|
||||
var schedulerCmd = &cobra.Command{
|
||||
Use: "scheduler",
|
||||
Short: "wavelet Scheduler",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
log.Println("[Scheduler] 启动定时任务调度服务")
|
||||
if err := scheduler.StartScheduler(); err != nil {
|
||||
log.Fatalf("[调度器] 启动失败: %v", err)
|
||||
|
||||
@@ -28,7 +28,7 @@ import (
|
||||
var workerCmd = &cobra.Command{
|
||||
Use: "worker",
|
||||
Short: "wavelet Worker",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
log.Println("[Worker] 启动任务处理服务")
|
||||
if err := worker.StartWorker(); err != nil {
|
||||
log.Fatalf("[工作器] 启动失败: %v", err)
|
||||
|
||||
@@ -43,7 +43,7 @@ type appConfig struct {
|
||||
SessionSecret string `mapstructure:"session_secret"`
|
||||
SessionDomain string `mapstructure:"session_domain"`
|
||||
SessionAge int `mapstructure:"session_age"`
|
||||
SessionHttpOnly bool `mapstructure:"session_http_only"`
|
||||
SessionHTTPOnly bool `mapstructure:"session_http_only"`
|
||||
SessionSecure bool `mapstructure:"session_secure"`
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// Package migrator 提供数据库自动迁移功能
|
||||
package migrator
|
||||
|
||||
import (
|
||||
@@ -37,6 +38,7 @@ func dbType() string {
|
||||
return "PostgreSQL"
|
||||
}
|
||||
|
||||
// Migrate 执行数据库自动迁移,初始化系统配置、默认管理员和内置模板
|
||||
func Migrate() {
|
||||
if err := db.DB(context.Background()).AutoMigrate(
|
||||
&model.User{},
|
||||
@@ -276,7 +278,7 @@ func initDefaultAdmin() {
|
||||
Username: "admin",
|
||||
Password: "12345678", // 密码使用明文存储
|
||||
Nickname: "Administrator",
|
||||
AvatarUrl: "",
|
||||
AvatarURL: "",
|
||||
IsActive: true,
|
||||
IsAdmin: true,
|
||||
LastLoginAt: time.Now(),
|
||||
|
||||
+13
-7
@@ -31,20 +31,20 @@ import (
|
||||
|
||||
// OAuthUserInfo 用户信息结构(同时支持 OIDC ID Token claims 和 UserEndpoint 响应)
|
||||
type OAuthUserInfo struct {
|
||||
Id uint64 `json:"id"`
|
||||
ID uint64 `json:"id"`
|
||||
Sub string `json:"sub"`
|
||||
Username string `json:"username"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Active bool `json:"active"`
|
||||
AvatarUrl string `json:"avatar_url"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
// GetID 获取用户 ID
|
||||
func (u *OAuthUserInfo) GetID() uint64 {
|
||||
if u.Id != 0 {
|
||||
return u.Id
|
||||
if u.ID != 0 {
|
||||
return u.ID
|
||||
}
|
||||
// 从 sub 解析(OIDC 格式)
|
||||
if u.Sub != "" {
|
||||
@@ -55,13 +55,14 @@ func (u *OAuthUserInfo) GetID() uint64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// User 用户表实体
|
||||
type User struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey"`
|
||||
Username string `json:"username" gorm:"size:64;uniqueIndex"`
|
||||
Password string `json:"password,omitempty" gorm:"size:255"`
|
||||
Nickname string `json:"nickname" gorm:"size:255"`
|
||||
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"`
|
||||
IsAdmin bool `json:"is_admin" gorm:"default:false"`
|
||||
Bio string `json:"bio" gorm:"size:500"`
|
||||
@@ -74,11 +75,13 @@ type User struct {
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
|
||||
}
|
||||
|
||||
// SetPassword 设置明文密码
|
||||
func (u *User) SetPassword(password string) error {
|
||||
u.Password = password
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetEncryptedPassword 设置加密密码
|
||||
func (u *User) SetEncryptedPassword(password string) error {
|
||||
if password == "" {
|
||||
u.Password = ""
|
||||
@@ -92,10 +95,12 @@ func (u *User) SetEncryptedPassword(password string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsPasswordEncrypted 检查密码是否已加密
|
||||
func (u *User) IsPasswordEncrypted() bool {
|
||||
return strings.HasPrefix(u.Password, "$2a$") || strings.HasPrefix(u.Password, "$2b$") || strings.HasPrefix(u.Password, "$2y$")
|
||||
}
|
||||
|
||||
// CheckPassword 验证密码是否匹配
|
||||
func (u *User) CheckPassword(password string) bool {
|
||||
if u.Password == "" || password == "" {
|
||||
return false
|
||||
@@ -106,6 +111,7 @@ func (u *User) CheckPassword(password string) bool {
|
||||
return u.Password == password
|
||||
}
|
||||
|
||||
// GetByID 根据 ID 查询用户
|
||||
func (u *User) GetByID(tx *gorm.DB, id uint64) error {
|
||||
if err := tx.Where("id = ?", id).First(u).Error; err != nil {
|
||||
return err
|
||||
@@ -118,7 +124,7 @@ func (u *User) UpdateFromOAuthInfo(oauthInfo *OAuthUserInfo) {
|
||||
u.Username = oauthInfo.Username
|
||||
u.Nickname = oauthInfo.Name
|
||||
u.Email = oauthInfo.Email
|
||||
u.AvatarUrl = oauthInfo.AvatarUrl
|
||||
u.AvatarURL = oauthInfo.AvatarURL
|
||||
u.IsActive = oauthInfo.Active
|
||||
u.LastLoginAt = time.Now()
|
||||
}
|
||||
@@ -144,7 +150,7 @@ func (u *User) CreateUser(ctx context.Context, tx *gorm.DB, oauthInfo *OAuthUser
|
||||
Username: oauthInfo.Username,
|
||||
Nickname: oauthInfo.Name,
|
||||
Email: oauthInfo.Email,
|
||||
AvatarUrl: oauthInfo.AvatarUrl,
|
||||
AvatarURL: oauthInfo.AvatarURL,
|
||||
IsActive: oauthInfo.Active,
|
||||
LastLoginAt: now,
|
||||
IsAdmin: false,
|
||||
|
||||
@@ -17,10 +17,11 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// Package router 提供 HTTP 路由注册与服务启动
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func registerFrontend(r *gin.Engine) {
|
||||
func registerFrontend(_ *gin.Engine) {
|
||||
// No-op when not embedding frontend
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ import (
|
||||
"github.com/Rain-kl/Wavelet/internal/util"
|
||||
capUtil "github.com/Rain-kl/Wavelet/internal/util/cap"
|
||||
|
||||
// Swagger 文档生成
|
||||
_ "github.com/Rain-kl/Wavelet/docs"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/admin/system_config"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
||||
@@ -58,6 +59,7 @@ import (
|
||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
|
||||
)
|
||||
|
||||
// Serve 启动 HTTP API 服务
|
||||
func Serve() {
|
||||
// 运行模式
|
||||
if config.Config.App.IsProduction() {
|
||||
@@ -267,13 +269,15 @@ func Serve() {
|
||||
<-quit
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Duration(config.Config.App.GracefulShutdownTimeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
otel_trace.Shutdown(shutdownCtx)
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -60,7 +60,8 @@ func NewMemoryStore(cleanupInterval time.Duration) *MemoryStore {
|
||||
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()
|
||||
defer s.mu.Unlock()
|
||||
return s.getLocked(key)
|
||||
@@ -79,7 +80,8 @@ func (s *MemoryStore) getLocked(key string) (string, bool, error) {
|
||||
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()
|
||||
defer s.mu.Unlock()
|
||||
s.items[key] = memoryItem{
|
||||
@@ -89,7 +91,8 @@ func (s *MemoryStore) Set(ctx context.Context, key string, val string, ttl time.
|
||||
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()
|
||||
defer s.mu.Unlock()
|
||||
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).
|
||||
// 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()
|
||||
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.
|
||||
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()
|
||||
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) {
|
||||
val, err := s.client.Get(ctx, key).Result()
|
||||
if err == redis.Nil {
|
||||
@@ -167,10 +171,12 @@ func (s *RedisStore) Get(ctx context.Context, key string) (string, bool, error)
|
||||
return val, true, nil
|
||||
}
|
||||
|
||||
// Set 向 RedisStore 写入指定 key 的值
|
||||
func (s *RedisStore) Set(ctx context.Context, key string, val string, ttl time.Duration) error {
|
||||
return s.client.Set(ctx, key, val, ttl).Err()
|
||||
}
|
||||
|
||||
// Delete 从 RedisStore 删除指定 key
|
||||
func (s *RedisStore) Delete(ctx context.Context, key string) error {
|
||||
return s.client.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
@@ -22,12 +22,13 @@ import (
|
||||
"github.com/gin-contrib/sessions"
|
||||
)
|
||||
|
||||
// GetSessionOptions 根据配置构建 Session 选项
|
||||
func GetSessionOptions(maxAge int) sessions.Options {
|
||||
return sessions.Options{
|
||||
Path: "/",
|
||||
Domain: config.Config.App.SessionDomain,
|
||||
MaxAge: maxAge,
|
||||
HttpOnly: config.Config.App.SessionHttpOnly,
|
||||
HttpOnly: config.Config.App.SessionHTTPOnly,
|
||||
Secure: config.Config.App.SessionSecure,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user