mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
393 lines
12 KiB
Go
393 lines
12 KiB
Go
// Copyright 2026 Arctel.net
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package user
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
|
"github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen"
|
|
"github.com/Rain-kl/Wavelet/internal/listener"
|
|
"github.com/Rain-kl/Wavelet/internal/model"
|
|
"github.com/Rain-kl/Wavelet/internal/shared/response"
|
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
|
pkgu "github.com/Rain-kl/Wavelet/pkg/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"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
// Login 用户密码登录
|
|
// @Summary 用户密码登录
|
|
// @Description 使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。
|
|
// @Tags user
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body user.loginRequest true "登录请求参数"
|
|
// @Success 200 {object} response.Any{data=oauth.BasicUserInfo} "登录成功,返回用户信息"
|
|
// @Failure 400 {object} response.Any "用户名或密码错误"
|
|
// @Failure 500 {object} response.Any "服务内部错误"
|
|
// @Router /api/v1/user/login [post]
|
|
func Login(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
if !isPasswordLoginEnabled(ctx) {
|
|
response.AbortBadRequest(c, errPasswordLoginDisabled)
|
|
return
|
|
}
|
|
if loginAttemptsBlocked(ctx, c.ClientIP()) {
|
|
response.AbortBadRequest(c, errLoginRateLimited)
|
|
return
|
|
}
|
|
var req loginRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.AbortBadRequest(c, err.Error())
|
|
return
|
|
}
|
|
req.Username = strings.TrimSpace(req.Username)
|
|
if req.Username == "" || req.Password == "" {
|
|
response.AbortBadRequest(c, errInvalidParams)
|
|
return
|
|
}
|
|
|
|
user, err := getUserByUsernameOrEmail(ctx, req.Username)
|
|
if err != nil {
|
|
pkgu.DummyCheckPassword(req.Password)
|
|
recordFailedLogin(ctx, c.ClientIP())
|
|
logger.WarnF(ctx, "[LoginAudit] failed login attempt (username not found) for input: %s, IP: %s", req.Username, c.ClientIP())
|
|
response.AbortBadRequest(c, errUsernameOrPasswordWrong)
|
|
return
|
|
}
|
|
if !user.IsActive {
|
|
recordFailedLogin(ctx, c.ClientIP())
|
|
logger.WarnF(ctx, "[LoginAudit] banned user login attempt for username: %s, ID: %d, IP: %s", user.Username, user.ID, c.ClientIP())
|
|
response.AbortBadRequest(c, errUsernameOrPasswordWrong)
|
|
return
|
|
}
|
|
|
|
// 判定是否是明文密码存储
|
|
isPlaintext := !user.IsPasswordEncrypted()
|
|
|
|
if !user.CheckPassword(req.Password) {
|
|
recordFailedLogin(ctx, c.ClientIP())
|
|
logger.WarnF(ctx, "[LoginAudit] failed login attempt (incorrect password) for username: %s, ID: %d, IP: %s", user.Username, user.ID, c.ClientIP())
|
|
response.AbortBadRequest(c, errUsernameOrPasswordWrong)
|
|
return
|
|
}
|
|
|
|
if isEmailLoginVerificationEnabled(ctx) {
|
|
result, err := processLoginEmailVerification(ctx, req.Code, user)
|
|
if err != nil {
|
|
response.AbortBadRequest(c, err.Error())
|
|
return
|
|
}
|
|
if result.Status != LoginEmailVerificationPassed {
|
|
response.AbortBadRequest(c, result.Message)
|
|
return
|
|
}
|
|
}
|
|
|
|
needChangePassword := isPlaintext
|
|
|
|
user.LastLoginAt = time.Now()
|
|
if err := updateLastLogin(ctx, user); err != nil {
|
|
response.AbortBadRequest(c, "更新登录时间失败,请稍后再试")
|
|
return
|
|
}
|
|
extras := map[string]any{}
|
|
if isPlaintext {
|
|
extras["need_change_password"] = true
|
|
}
|
|
clearFailedLogins(ctx, c.ClientIP())
|
|
if err := oauth.SetLoginSession(ctx, c, user, extras); err != nil {
|
|
response.AbortBadRequest(c, errSaveSessionFailed)
|
|
return
|
|
}
|
|
|
|
oauth.SetCachedUser(ctx, user.ID, user)
|
|
|
|
logger.InfoF(ctx, "[LoginAudit] successful login for user: %s, ID: %d, IP: %s", user.Username, user.ID, c.ClientIP())
|
|
|
|
listener.EmitAdminLoggedIn(ctx, user, c.ClientIP())
|
|
|
|
c.JSON(http.StatusOK, response.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} response.Any{data=oauth.BasicUserInfo} "注册并登录成功,返回用户信息"
|
|
// @Failure 400 {object} response.Any "参数错误、用户名已存在或注册已关闭"
|
|
// @Failure 500 {object} response.Any "服务内部错误"
|
|
// @Router /api/v1/user/register [post]
|
|
func Register(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
if !isRegistrationEnabled(ctx) || !isPasswordRegisterEnabled(ctx) {
|
|
response.AbortBadRequest(c, errRegistrationDisabled)
|
|
return
|
|
}
|
|
|
|
var req registerRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.AbortBadRequest(c, 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 == "" {
|
|
response.AbortBadRequest(c, errInvalidParams)
|
|
return
|
|
}
|
|
if req.Email == "" {
|
|
response.AbortBadRequest(c, errEmailRequired)
|
|
return
|
|
}
|
|
if len(req.Password) < minPasswordLength {
|
|
response.AbortBadRequest(c, errPasswordTooShort)
|
|
return
|
|
}
|
|
|
|
// 邮箱注册验证校验
|
|
if err := validateRegisterEmailVerification(ctx, req.Email, req.Code); err != nil {
|
|
response.AbortBadRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
user := model.User{
|
|
ID: idgen.NextUint64ID(),
|
|
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.SetEncryptedPassword(req.Password); err != nil {
|
|
response.AbortBadRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
if err := registerUserLogic(ctx, &user); err != nil {
|
|
response.AbortBadRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
if err := oauth.SetLoginSession(ctx, c, &user); err != nil {
|
|
response.AbortBadRequest(c, errSaveSessionFailed)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, response.OK(oauth.BuildBasicUserInfo(&user, false)))
|
|
}
|
|
|
|
// Logout 用户退出登录
|
|
// @Summary 用户退出登录
|
|
// @Description 清除用户登录 Session,完成退出
|
|
// @Tags user
|
|
// @Produce json
|
|
// @Security SessionCookie
|
|
// @Success 200 {object} response.Any{data=string} "退出成功"
|
|
// @Failure 500 {object} response.Any "Session 清除失败"
|
|
// @Router /api/v1/user/logout [get]
|
|
func Logout(c *gin.Context) {
|
|
session := sessions.Default(c)
|
|
userID := session.Get(oauth.UserIDKey)
|
|
username := session.Get(oauth.UserNameKey)
|
|
if userID != nil {
|
|
logger.InfoF(c.Request.Context(), "[LoginAudit] user logged out: %v, ID: %v, IP: %s", username, userID, c.ClientIP())
|
|
if id, ok := userID.(uint64); ok {
|
|
oauth.InvalidateCachedUser(c.Request.Context(), id)
|
|
} else if idFloat, ok := userID.(float64); ok {
|
|
oauth.InvalidateCachedUser(c.Request.Context(), uint64(idFloat))
|
|
} else if idInt, ok := userID.(int); ok && idInt >= 0 {
|
|
oauth.InvalidateCachedUser(c.Request.Context(), uint64(idInt))
|
|
}
|
|
}
|
|
session.Options(oauth.GetSessionOptions(-1))
|
|
session.Clear()
|
|
if err := session.Save(); err != nil {
|
|
response.AbortBadRequest(c, err.Error())
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, response.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} response.Any{data=string} "修改密码成功"
|
|
// @Failure 400 {object} response.Any "原密码错误或新密码不符合要求"
|
|
// @Failure 401 {object} response.Any "请先登录"
|
|
// @Router /api/v1/user/change-password [post]
|
|
func ChangePassword(c *gin.Context) {
|
|
var req changePasswordRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.AbortBadRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
req.OldPassword = strings.TrimSpace(req.OldPassword)
|
|
req.NewPassword = strings.TrimSpace(req.NewPassword)
|
|
|
|
if req.OldPassword == "" || req.NewPassword == "" {
|
|
response.AbortBadRequest(c, errInvalidParams)
|
|
return
|
|
}
|
|
if len(req.NewPassword) < minPasswordLength {
|
|
response.AbortBadRequest(c, errNewPasswordTooShort)
|
|
return
|
|
}
|
|
|
|
userObj, _ := oauth.GetFromContext[*model.User](c, oauth.UserObjKey)
|
|
if userObj == nil {
|
|
response.AbortUnauthorized(c, errLoginRequired)
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
if err := changePasswordLogic(ctx, userObj.ID, req.OldPassword, req.NewPassword); err != nil {
|
|
response.AbortBadRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
// 销毁当前活跃会话以强制重新登录
|
|
session := sessions.Default(c)
|
|
session.Clear()
|
|
_ = session.Save()
|
|
|
|
c.JSON(http.StatusOK, response.OK("密码修改成功"))
|
|
}
|
|
|
|
// SendEmailCode 发送邮箱验证码
|
|
// @Summary 发送邮箱验证码
|
|
// @Description 向指定邮箱发送验证码(用于注册场景)
|
|
// @Tags user
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body user.sendEmailCodeRequest true "发送验证码请求参数"
|
|
// @Success 200 {object} response.Any "发送成功"
|
|
// @Failure 400 {object} response.Any "参数错误"
|
|
// @Router /api/v1/user/send-email-code [post]
|
|
func SendEmailCode(c *gin.Context) {
|
|
var req sendEmailCodeRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.AbortBadRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
req.Email = strings.TrimSpace(req.Email)
|
|
if req.Email == "" {
|
|
response.AbortBadRequest(c, errEmailRequired)
|
|
return
|
|
}
|
|
|
|
if req.Scene != "register" {
|
|
response.AbortBadRequest(c, errUnsupportedEmailScene)
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
if err := sendRegisterEmailCode(ctx, req.Email); err != nil {
|
|
response.AbortBadRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, response.OKNil())
|
|
}
|
|
|
|
// UpdateProfile 修改当前登录用户的个人资料
|
|
// @Summary 修改当前登录用户的个人资料
|
|
// @Description 修改当前登录用户的昵称、邮箱、头像、简介、电话、性别、个人网站和所在地。
|
|
// @Tags user
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body user.updateProfileRequest true "更新请求参数"
|
|
// @Success 200 {object} response.Any{data=oauth.BasicUserInfo} "修改成功,返回更新后的用户信息"
|
|
// @Failure 400 {object} response.Any "邮箱已被占用或参数错误"
|
|
// @Failure 401 {object} response.Any "未登录"
|
|
// @Router /api/v1/user/profile [put]
|
|
func UpdateProfile(c *gin.Context) {
|
|
var req updateProfileRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.AbortBadRequest(c, err.Error())
|
|
return
|
|
}
|
|
|
|
userObj, _ := oauth.GetFromContext[*model.User](c, oauth.UserObjKey)
|
|
if userObj == nil {
|
|
response.AbortUnauthorized(c, errLoginRequired)
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
dbUser, err := updateUserProfile(ctx, userObj.ID, updateProfileInput(req))
|
|
if err != nil {
|
|
response.AbortBadRequest(c, err.Error())
|
|
return
|
|
}
|
|
oauth.InvalidateCachedUser(ctx, userObj.ID)
|
|
|
|
session := sessions.Default(c)
|
|
needChange := session.Get("need_change_password") == true
|
|
|
|
c.JSON(http.StatusOK, response.OK(oauth.BuildBasicUserInfo(dbUser, needChange)))
|
|
}
|