diff --git a/internal/controller/v1/user.go b/internal/controller/v1/user.go index b010540..ef20db6 100644 --- a/internal/controller/v1/user.go +++ b/internal/controller/v1/user.go @@ -10,8 +10,10 @@ import ( "github.com/snowykami/neo-blog/internal/ctxutils" "github.com/snowykami/neo-blog/internal/dto" "github.com/snowykami/neo-blog/internal/service" + "github.com/snowykami/neo-blog/pkg/constant" "github.com/snowykami/neo-blog/pkg/errs" "github.com/snowykami/neo-blog/pkg/resps" + utils2 "github.com/snowykami/neo-blog/pkg/utils" ) type UserController struct { @@ -125,6 +127,22 @@ func (u *UserController) GetUser(ctx context.Context, c *app.RequestContext) { return } resps.Ok(c, resps.Success, resp.User) + +} + +func (u *UserController) GetUserByUsername(ctx context.Context, c *app.RequestContext) { + username := c.Param("username") + if username == "" { + resps.BadRequest(c, resps.ErrParamInvalid) + return + } + resp, err := u.service.GetUserByUsername(&dto.GetUserByUsernameReq{Username: username}) + if err != nil { + serviceErr := errs.AsServiceError(err) + resps.Custom(c, serviceErr.Code, serviceErr.Message, nil) + return + } + resps.Ok(c, resps.Success, resp.User) } func (u *UserController) UpdateUser(ctx context.Context, c *app.RequestContext) { @@ -184,3 +202,11 @@ func (u *UserController) ChangePassword(ctx context.Context, c *app.RequestConte func (u *UserController) ChangeEmail(ctx context.Context, c *app.RequestContext) { // TODO: 实现修改邮箱功能 } + +func (u *UserController) GetCaptchaConfig(ctx context.Context, c *app.RequestContext) { + resps.Ok(c, "ok", utils.H{ + "provider": utils2.Env.Get(constant.EnvKeyCaptchaProvider), + "site_key": utils2.Env.Get(constant.EnvKeyCaptchaSiteKey), + "url": utils2.Env.Get(constant.EnvKeyCaptchaUrl), + }) +} diff --git a/internal/dto/user.go b/internal/dto/user.go index 8ac83e2..0f0dc1b 100644 --- a/internal/dto/user.go +++ b/internal/dto/user.go @@ -70,6 +70,10 @@ type GetUserReq struct { UserID uint `json:"user_id"` } +type GetUserByUsernameReq struct { + Username string `json:"username"` +} + type GetUserResp struct { User UserDto `json:"user"` // 用户信息 } diff --git a/internal/middleware/captcha.go b/internal/middleware/captcha.go index 46002c8..dc78731 100644 --- a/internal/middleware/captcha.go +++ b/internal/middleware/captcha.go @@ -2,6 +2,7 @@ package middleware import ( "context" + "github.com/cloudwego/hertz/pkg/app" "github.com/sirupsen/logrus" "github.com/snowykami/neo-blog/pkg/resps" diff --git a/internal/router/apiv1/user.go b/internal/router/apiv1/user.go index 25bba99..08c8072 100644 --- a/internal/router/apiv1/user.go +++ b/internal/router/apiv1/user.go @@ -1,27 +1,29 @@ package apiv1 import ( - "github.com/cloudwego/hertz/pkg/route" - "github.com/snowykami/neo-blog/internal/controller/v1" - "github.com/snowykami/neo-blog/internal/middleware" + "github.com/cloudwego/hertz/pkg/route" + "github.com/snowykami/neo-blog/internal/controller/v1" + "github.com/snowykami/neo-blog/internal/middleware" ) func registerUserRoutes(group *route.RouterGroup) { - userController := v1.NewUserController() - userGroup := group.Group("/user").Use(middleware.UseAuth(true)) - userGroupWithoutAuth := group.Group("/user").Use(middleware.UseAuth(false)) - userGroupWithoutAuthNeedsCaptcha := userGroupWithoutAuth.Use(middleware.UseCaptcha()) - { - userGroupWithoutAuthNeedsCaptcha.POST("/login", userController.Login) - userGroupWithoutAuthNeedsCaptcha.POST("/register", userController.Register) - userGroupWithoutAuthNeedsCaptcha.POST("/email/verify", userController.VerifyEmail) // Send email verification code - userGroupWithoutAuth.GET("/oidc/list", userController.OidcList) - userGroupWithoutAuth.GET("/oidc/login/:name", userController.OidcLogin) - userGroupWithoutAuth.GET("/u/:id", userController.GetUser) - userGroup.GET("/me", userController.GetUser) - userGroupWithoutAuth.POST("/logout", userController.Logout) - userGroup.PUT("/u/:id", userController.UpdateUser) - userGroup.PUT("/password/edit", userController.ChangePassword) - userGroup.PUT("/email/edit", userController.ChangeEmail) - } + userController := v1.NewUserController() + userGroup := group.Group("/user").Use(middleware.UseAuth(true)) + userGroupWithoutAuth := group.Group("/user").Use(middleware.UseAuth(false)) + userGroupWithoutAuthNeedsCaptcha := group.Group("/user").Use(middleware.UseCaptcha()) + { + userGroupWithoutAuthNeedsCaptcha.POST("/login", userController.Login) + userGroupWithoutAuthNeedsCaptcha.POST("/register", userController.Register) + userGroupWithoutAuthNeedsCaptcha.POST("/email/verify", userController.VerifyEmail) // Send email verification code + userGroupWithoutAuth.GET("/captcha", userController.GetCaptchaConfig) + userGroupWithoutAuth.GET("/oidc/list", userController.OidcList) + userGroupWithoutAuth.GET("/oidc/login/:name", userController.OidcLogin) + userGroupWithoutAuth.GET("/u/:id", userController.GetUser) + userGroupWithoutAuth.GET("/username/:username", userController.GetUserByUsername) + userGroup.GET("/me", userController.GetUser) + userGroupWithoutAuth.POST("/logout", userController.Logout) + userGroup.PUT("/u/:id", userController.UpdateUser) + userGroup.PUT("/password/edit", userController.ChangePassword) + userGroup.PUT("/email/edit", userController.ChangeEmail) + } } diff --git a/internal/service/user.go b/internal/service/user.go index 0a533d7..c28b9ed 100644 --- a/internal/service/user.go +++ b/internal/service/user.go @@ -1,379 +1,400 @@ package service import ( - "errors" - "fmt" - "github.com/sirupsen/logrus" - "github.com/snowykami/neo-blog/internal/dto" - "github.com/snowykami/neo-blog/internal/model" - "github.com/snowykami/neo-blog/internal/repo" - "github.com/snowykami/neo-blog/internal/static" - "github.com/snowykami/neo-blog/pkg/constant" - "github.com/snowykami/neo-blog/pkg/errs" - "github.com/snowykami/neo-blog/pkg/utils" - "gorm.io/gorm" - "net/http" - "strings" - "time" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/sirupsen/logrus" + "github.com/snowykami/neo-blog/internal/dto" + "github.com/snowykami/neo-blog/internal/model" + "github.com/snowykami/neo-blog/internal/repo" + "github.com/snowykami/neo-blog/internal/static" + "github.com/snowykami/neo-blog/pkg/constant" + "github.com/snowykami/neo-blog/pkg/errs" + "github.com/snowykami/neo-blog/pkg/utils" + "gorm.io/gorm" ) type UserService struct{} func NewUserService() *UserService { - return &UserService{} + return &UserService{} } func (s *UserService) UserLogin(req *dto.UserLoginReq) (*dto.UserLoginResp, error) { - user, err := repo.User.GetUserByUsernameOrEmail(req.Username) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - logrus.Warnf("User not found: %s", req.Username) - return nil, errs.ErrNotFound - } - return nil, errs.ErrInternalServer - } - if user == nil { - return nil, errs.ErrNotFound - } - if utils.Password.VerifyPassword(req.Password, user.Password, utils.Env.Get(constant.EnvKeyPasswordSalt, "default_salt")) { - token, refreshToken, err := s.generate2Token(user.ID) - if err != nil { - logrus.Errorln("Failed to generate tokens:", err) - return nil, errs.ErrInternalServer - } - resp := &dto.UserLoginResp{ - Token: token, - RefreshToken: refreshToken, - User: user.ToDto(), - } - return resp, nil - } else { - return nil, errs.ErrInternalServer - } + user, err := repo.User.GetUserByUsernameOrEmail(req.Username) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + logrus.Warnf("User not found: %s", req.Username) + return nil, errs.ErrNotFound + } + return nil, errs.ErrInternalServer + } + if user == nil { + return nil, errs.ErrNotFound + } + if utils.Password.VerifyPassword(req.Password, user.Password, utils.Env.Get(constant.EnvKeyPasswordSalt, "default_salt")) { + token, refreshToken, err := s.generate2Token(user.ID) + if err != nil { + logrus.Errorln("Failed to generate tokens:", err) + return nil, errs.ErrInternalServer + } + resp := &dto.UserLoginResp{ + Token: token, + RefreshToken: refreshToken, + User: user.ToDto(), + } + return resp, nil + } else { + return nil, errs.ErrInternalServer + } } func (s *UserService) UserRegister(req *dto.UserRegisterReq) (*dto.UserRegisterResp, error) { - // 验证邮箱验证码 - if !utils.Env.GetAsBool("ENABLE_REGISTER", true) { - return nil, errs.ErrForbidden - } - if utils.Env.GetAsBool("ENABLE_EMAIL_VERIFICATION", true) { - ok, err := s.verifyEmail(req.Email, req.VerificationCode) - if err != nil { - logrus.Errorln("Failed to verify email:", err) - return nil, errs.ErrInternalServer - } - if !ok { - return nil, errs.New(http.StatusForbidden, "Invalid email verification code", nil) - } - } - // 检查用户名或邮箱是否已存在 - usernameExist, err := repo.User.CheckUsernameExists(req.Username) - if err != nil { - return nil, errs.ErrInternalServer - } - emailExist, err := repo.User.CheckEmailExists(req.Email) - if err != nil { - return nil, errs.ErrInternalServer - } - if usernameExist || emailExist { - return nil, errs.New(http.StatusConflict, "Username or email already exists", nil) - } - // 创建新用户 - hashedPassword, err := utils.Password.HashPassword(req.Password, utils.Env.Get(constant.EnvKeyPasswordSalt, "default_salt")) - if err != nil { - logrus.Errorln("Failed to hash password:", err) - return nil, errs.ErrInternalServer - } - newUser := &model.User{ - Username: req.Username, - Nickname: req.Nickname, - Email: req.Email, - Gender: "", - Role: "user", - Password: hashedPassword, - } - err = repo.User.CreateUser(newUser) - if err != nil { - return nil, errs.ErrInternalServer - } - // 创建默认管理员账户 - if newUser.ID == 1 { - newUser.Role = constant.RoleAdmin - err = repo.User.UpdateUser(newUser) - if err != nil { - logrus.Errorln("Failed to update user role to admin:", err) - return nil, errs.ErrInternalServer - } - } - // 生成访问令牌和刷新令牌 - token, refreshToken, err := s.generate2Token(newUser.ID) - if err != nil { - logrus.Errorln("Failed to generate tokens:", err) - return nil, errs.ErrInternalServer - } - resp := &dto.UserRegisterResp{ - Token: token, - RefreshToken: refreshToken, - User: newUser.ToDto(), - } - return resp, nil + // 验证邮箱验证码 + if !utils.Env.GetAsBool("ENABLE_REGISTER", true) { + return nil, errs.ErrForbidden + } + if utils.Env.GetAsBool("ENABLE_EMAIL_VERIFICATION", true) { + ok, err := s.verifyEmail(req.Email, req.VerificationCode) + if err != nil { + logrus.Errorln("Failed to verify email:", err) + return nil, errs.ErrInternalServer + } + if !ok { + return nil, errs.New(http.StatusForbidden, "Invalid email verification code", nil) + } + } + // 检查用户名或邮箱是否已存在 + usernameExist, err := repo.User.CheckUsernameExists(req.Username) + if err != nil { + return nil, errs.ErrInternalServer + } + emailExist, err := repo.User.CheckEmailExists(req.Email) + if err != nil { + return nil, errs.ErrInternalServer + } + if usernameExist || emailExist { + return nil, errs.New(http.StatusConflict, "Username or email already exists", nil) + } + // 创建新用户 + hashedPassword, err := utils.Password.HashPassword(req.Password, utils.Env.Get(constant.EnvKeyPasswordSalt, "default_salt")) + if err != nil { + logrus.Errorln("Failed to hash password:", err) + return nil, errs.ErrInternalServer + } + newUser := &model.User{ + Username: req.Username, + Nickname: req.Nickname, + Email: req.Email, + Gender: "", + Role: "user", + Password: hashedPassword, + } + err = repo.User.CreateUser(newUser) + if err != nil { + return nil, errs.ErrInternalServer + } + // 创建默认管理员账户 + if newUser.ID == 1 { + newUser.Role = constant.RoleAdmin + err = repo.User.UpdateUser(newUser) + if err != nil { + logrus.Errorln("Failed to update user role to admin:", err) + return nil, errs.ErrInternalServer + } + } + // 生成访问令牌和刷新令牌 + token, refreshToken, err := s.generate2Token(newUser.ID) + if err != nil { + logrus.Errorln("Failed to generate tokens:", err) + return nil, errs.ErrInternalServer + } + resp := &dto.UserRegisterResp{ + Token: token, + RefreshToken: refreshToken, + User: newUser.ToDto(), + } + return resp, nil } func (s *UserService) RequestVerifyEmail(req *dto.VerifyEmailReq) (*dto.VerifyEmailResp, error) { - generatedVerificationCode := utils.Strings.GenerateRandomStringWithCharset(6, "0123456789abcdef") - kv := utils.KV.GetInstance() - kv.Set(constant.KVKeyEmailVerificationCode+req.Email, generatedVerificationCode, time.Minute*10) + generatedVerificationCode := utils.Strings.GenerateRandomStringWithCharset(6, "0123456789abcdef") + kv := utils.KV.GetInstance() + kv.Set(constant.KVKeyEmailVerificationCode+req.Email, generatedVerificationCode, time.Minute*10) - template, err := static.RenderTemplate("email/verification-code.tmpl", map[string]interface{}{}) - if err != nil { - return nil, errs.ErrInternalServer - } - if utils.IsDevMode { - logrus.Infof("%s's verification code is %s", req.Email, generatedVerificationCode) - } - err = utils.Email.SendEmail(utils.Email.GetEmailConfigFromEnv(), req.Email, "验证你的电子邮件 / Verify your email", template, true) + template, err := static.RenderTemplate("email/verification-code.tmpl", map[string]interface{}{}) + if err != nil { + return nil, errs.ErrInternalServer + } + if utils.IsDevMode { + logrus.Infof("%s's verification code is %s", req.Email, generatedVerificationCode) + } + err = utils.Email.SendEmail(utils.Email.GetEmailConfigFromEnv(), req.Email, "验证你的电子邮件 / Verify your email", template, true) - if err != nil { - return nil, errs.ErrInternalServer - } - return &dto.VerifyEmailResp{Success: true}, nil + if err != nil { + return nil, errs.ErrInternalServer + } + return &dto.VerifyEmailResp{Success: true}, nil } func (s *UserService) ListOidcConfigs() ([]dto.UserOidcConfigDto, error) { - enabledOidcConfigs, err := repo.Oidc.ListOidcConfigs(true) - if err != nil { - return nil, errs.ErrInternalServer - } - var oidcConfigsDtos []dto.UserOidcConfigDto + enabledOidcConfigs, err := repo.Oidc.ListOidcConfigs(true) + if err != nil { + return nil, errs.ErrInternalServer + } + var oidcConfigsDtos []dto.UserOidcConfigDto - for _, oidcConfig := range enabledOidcConfigs { - state := utils.Strings.GenerateRandomString(32) - kvStore := utils.KV.GetInstance() - kvStore.Set(constant.KVKeyOidcState+state, oidcConfig.Name, 5*time.Minute) - loginUrl := utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{ - "client_id": oidcConfig.ClientID, - "redirect_uri": fmt.Sprintf("%s%s%s/%sREDIRECT_BACK", // 这个大占位符给前端替换用的,替换时也要uri编码因为是层层包的 - strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/"), - constant.ApiSuffix, - constant.OidcUri, - oidcConfig.Name, - ), - "response_type": "code", - "scope": "openid email profile", - "state": state, - }) + for _, oidcConfig := range enabledOidcConfigs { + state := utils.Strings.GenerateRandomString(32) + kvStore := utils.KV.GetInstance() + kvStore.Set(constant.KVKeyOidcState+state, oidcConfig.Name, 5*time.Minute) + loginUrl := utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{ + "client_id": oidcConfig.ClientID, + "redirect_uri": fmt.Sprintf("%s%s%s/%sREDIRECT_BACK", // 这个大占位符给前端替换用的,替换时也要uri编码因为是层层包的 + strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/"), + constant.ApiSuffix, + constant.OidcUri, + oidcConfig.Name, + ), + "response_type": "code", + "scope": "openid email profile", + "state": state, + }) - if oidcConfig.Type == constant.OidcProviderTypeMisskey { - // Misskey OIDC 特殊处理 - loginUrl = utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{ - "client_id": oidcConfig.ClientID, - "redirect_uri": fmt.Sprintf("%s%s%s/%s", // 这个大占位符给前端替换用的,替换时也要uri编码因为是层层包的 - strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/"), - constant.ApiSuffix, - constant.OidcUri, - oidcConfig.Name, - ), - "response_type": "code", - "scope": "read:account", - "state": state, - }) - } + if oidcConfig.Type == constant.OidcProviderTypeMisskey { + // Misskey OIDC 特殊处理 + loginUrl = utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{ + "client_id": oidcConfig.ClientID, + "redirect_uri": fmt.Sprintf("%s%s%s/%s", // 这个大占位符给前端替换用的,替换时也要uri编码因为是层层包的 + strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/"), + constant.ApiSuffix, + constant.OidcUri, + oidcConfig.Name, + ), + "response_type": "code", + "scope": "read:account", + "state": state, + }) + } - oidcConfigsDtos = append(oidcConfigsDtos, dto.UserOidcConfigDto{ - Name: oidcConfig.Name, - DisplayName: oidcConfig.DisplayName, - Icon: oidcConfig.Icon, - LoginUrl: loginUrl, - }) - } - return oidcConfigsDtos, nil + oidcConfigsDtos = append(oidcConfigsDtos, dto.UserOidcConfigDto{ + Name: oidcConfig.Name, + DisplayName: oidcConfig.DisplayName, + Icon: oidcConfig.Icon, + LoginUrl: loginUrl, + }) + } + return oidcConfigsDtos, nil } func (s *UserService) OidcLogin(req *dto.OidcLoginReq) (*dto.OidcLoginResp, error) { - // 验证state - kvStore := utils.KV.GetInstance() - storedName, ok := kvStore.Get(constant.KVKeyOidcState + req.State) - if !ok || storedName != req.Name { - return nil, errs.New(http.StatusForbidden, "invalid oidc state", nil) - } - // 获取OIDC配置 - oidcConfig, err := repo.Oidc.GetOidcConfigByName(req.Name) - if err != nil { - return nil, errs.ErrInternalServer - } - if oidcConfig == nil { - return nil, errs.New(http.StatusNotFound, "OIDC configuration not found", nil) - } - // 请求访问令牌 - tokenResp, err := utils.Oidc.RequestToken( - oidcConfig.TokenEndpoint, - oidcConfig.ClientID, - oidcConfig.ClientSecret, - req.Code, - strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/")+constant.OidcUri+oidcConfig.Name, - ) - if err != nil { - logrus.Errorln("Failed to request OIDC token:", err) - return nil, errs.ErrInternalServer - } - userInfo, err := utils.Oidc.RequestUserInfo(oidcConfig.UserInfoEndpoint, tokenResp.AccessToken) - if err != nil { - logrus.Errorln("Failed to request OIDC user info:", err) - return nil, errs.ErrInternalServer - } + // 验证state + kvStore := utils.KV.GetInstance() + storedName, ok := kvStore.Get(constant.KVKeyOidcState + req.State) + if !ok || storedName != req.Name { + return nil, errs.New(http.StatusForbidden, "invalid oidc state", nil) + } + // 获取OIDC配置 + oidcConfig, err := repo.Oidc.GetOidcConfigByName(req.Name) + if err != nil { + return nil, errs.ErrInternalServer + } + if oidcConfig == nil { + return nil, errs.New(http.StatusNotFound, "OIDC configuration not found", nil) + } + // 请求访问令牌 + tokenResp, err := utils.Oidc.RequestToken( + oidcConfig.TokenEndpoint, + oidcConfig.ClientID, + oidcConfig.ClientSecret, + req.Code, + strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/")+constant.OidcUri+oidcConfig.Name, + ) + if err != nil { + logrus.Errorln("Failed to request OIDC token:", err) + return nil, errs.ErrInternalServer + } + userInfo, err := utils.Oidc.RequestUserInfo(oidcConfig.UserInfoEndpoint, tokenResp.AccessToken) + if err != nil { + logrus.Errorln("Failed to request OIDC user info:", err) + return nil, errs.ErrInternalServer + } - // 绑定过登录 - userOpenID, err := repo.User.GetUserOpenIDByIssuerAndSub(oidcConfig.Issuer, userInfo.Sub) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errs.ErrInternalServer - } - if userOpenID != nil { - user, err := repo.User.GetUserByID(userOpenID.UserID) - if err != nil { - return nil, errs.ErrInternalServer - } - token, refreshToken, err := s.generate2Token(user.ID) - if err != nil { - logrus.Errorln("Failed to generate tokens:", err) - return nil, errs.ErrInternalServer - } - resp := &dto.OidcLoginResp{ - Token: token, - RefreshToken: refreshToken, - User: user.ToDto(), - } - return resp, nil - } else { - // 若没有绑定过登录,则先通过邮箱查找用户,若没有再创建新用户 - user, err := repo.User.GetUserByEmail(userInfo.Email) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - logrus.Errorln("Failed to get user by email:", err) - return nil, errs.ErrInternalServer - } - if user != nil { - userOpenID = &model.UserOpenID{ - UserID: user.ID, - Issuer: oidcConfig.Issuer, - Sub: userInfo.Sub, - } - err = repo.User.CreateOrUpdateUserOpenID(userOpenID) - if err != nil { - logrus.Errorln("Failed to create or update user OpenID:", err) - return nil, errs.ErrInternalServer - } - token, refreshToken, err := s.generate2Token(user.ID) - if err != nil { - logrus.Errorln("Failed to generate tokens:", err) - return nil, errs.ErrInternalServer - } - resp := &dto.OidcLoginResp{ - Token: token, - RefreshToken: refreshToken, - User: user.ToDto(), - } - return resp, nil - } else { - user = &model.User{ - Username: userInfo.Name, - Nickname: userInfo.Name, - AvatarUrl: userInfo.Picture, - Email: userInfo.Email, - } - err = repo.User.CreateUser(user) - if err != nil { - logrus.Errorln("Failed to create user:", err) - return nil, errs.ErrInternalServer - } - userOpenID = &model.UserOpenID{ - UserID: user.ID, - Issuer: oidcConfig.Issuer, - Sub: userInfo.Sub, - } - err = repo.User.CreateOrUpdateUserOpenID(userOpenID) - if err != nil { - logrus.Errorln("Failed to create or update user OpenID:", err) - return nil, errs.ErrInternalServer - } - token, refreshToken, err := s.generate2Token(user.ID) - if err != nil { - logrus.Errorln("Failed to generate tokens:", err) - return nil, errs.ErrInternalServer - } - resp := &dto.OidcLoginResp{ - Token: token, - RefreshToken: refreshToken, - User: user.ToDto(), - } - return resp, nil - } - } + // 绑定过登录 + userOpenID, err := repo.User.GetUserOpenIDByIssuerAndSub(oidcConfig.Issuer, userInfo.Sub) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errs.ErrInternalServer + } + if userOpenID != nil { + user, err := repo.User.GetUserByID(userOpenID.UserID) + if err != nil { + return nil, errs.ErrInternalServer + } + token, refreshToken, err := s.generate2Token(user.ID) + if err != nil { + logrus.Errorln("Failed to generate tokens:", err) + return nil, errs.ErrInternalServer + } + resp := &dto.OidcLoginResp{ + Token: token, + RefreshToken: refreshToken, + User: user.ToDto(), + } + return resp, nil + } else { + // 若没有绑定过登录,则先通过邮箱查找用户,若没有再创建新用户 + user, err := repo.User.GetUserByEmail(userInfo.Email) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + logrus.Errorln("Failed to get user by email:", err) + return nil, errs.ErrInternalServer + } + if user != nil { + userOpenID = &model.UserOpenID{ + UserID: user.ID, + Issuer: oidcConfig.Issuer, + Sub: userInfo.Sub, + } + err = repo.User.CreateOrUpdateUserOpenID(userOpenID) + if err != nil { + logrus.Errorln("Failed to create or update user OpenID:", err) + return nil, errs.ErrInternalServer + } + token, refreshToken, err := s.generate2Token(user.ID) + if err != nil { + logrus.Errorln("Failed to generate tokens:", err) + return nil, errs.ErrInternalServer + } + resp := &dto.OidcLoginResp{ + Token: token, + RefreshToken: refreshToken, + User: user.ToDto(), + } + return resp, nil + } else { + user = &model.User{ + Username: userInfo.Name, + Nickname: userInfo.Name, + AvatarUrl: userInfo.Picture, + Email: userInfo.Email, + } + err = repo.User.CreateUser(user) + if err != nil { + logrus.Errorln("Failed to create user:", err) + return nil, errs.ErrInternalServer + } + userOpenID = &model.UserOpenID{ + UserID: user.ID, + Issuer: oidcConfig.Issuer, + Sub: userInfo.Sub, + } + err = repo.User.CreateOrUpdateUserOpenID(userOpenID) + if err != nil { + logrus.Errorln("Failed to create or update user OpenID:", err) + return nil, errs.ErrInternalServer + } + token, refreshToken, err := s.generate2Token(user.ID) + if err != nil { + logrus.Errorln("Failed to generate tokens:", err) + return nil, errs.ErrInternalServer + } + resp := &dto.OidcLoginResp{ + Token: token, + RefreshToken: refreshToken, + User: user.ToDto(), + } + return resp, nil + } + } } func (s *UserService) GetUser(req *dto.GetUserReq) (*dto.GetUserResp, error) { - if req.UserID == 0 { - return nil, errs.New(http.StatusBadRequest, "user_id is required", nil) - } - user, err := repo.User.GetUserByID(req.UserID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errs.ErrNotFound - } - logrus.Errorln("Failed to get user by ID:", err) - return nil, errs.ErrInternalServer - } - if user == nil { - return nil, errs.ErrNotFound - } - return &dto.GetUserResp{ - User: user.ToDto(), - }, nil + if req.UserID == 0 { + return nil, errs.New(http.StatusBadRequest, "user_id is required", nil) + } + user, err := repo.User.GetUserByID(req.UserID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errs.ErrNotFound + } + logrus.Errorln("Failed to get user by ID:", err) + return nil, errs.ErrInternalServer + } + if user == nil { + return nil, errs.ErrNotFound + } + return &dto.GetUserResp{ + User: user.ToDto(), + }, nil +} + +func (s *UserService) GetUserByUsername(req *dto.GetUserByUsernameReq) (*dto.GetUserResp, error) { + if req.Username == "" { + return nil, errs.New(http.StatusBadRequest, "username is required", nil) + } + user, err := repo.User.GetUserByUsername(req.Username) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errs.ErrNotFound + } + logrus.Errorln("Failed to get user by username:", err) + return nil, errs.ErrInternalServer + } + if user == nil { + return nil, errs.ErrNotFound + } + return &dto.GetUserResp{ + User: user.ToDto(), + }, nil } func (s *UserService) UpdateUser(req *dto.UpdateUserReq) (*dto.UpdateUserResp, error) { - user := &model.User{ - Model: gorm.Model{ - ID: req.ID, - }, - Username: req.Username, - Nickname: req.Nickname, - Gender: req.Gender, - AvatarUrl: req.AvatarUrl, - } - err := repo.User.UpdateUser(user) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errs.ErrNotFound - } - logrus.Errorln("Failed to update user:", err) - return nil, errs.ErrInternalServer - } - return &dto.UpdateUserResp{}, nil + user := &model.User{ + Model: gorm.Model{ + ID: req.ID, + }, + Username: req.Username, + Nickname: req.Nickname, + Gender: req.Gender, + AvatarUrl: req.AvatarUrl, + } + err := repo.User.UpdateUser(user) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errs.ErrNotFound + } + logrus.Errorln("Failed to update user:", err) + return nil, errs.ErrInternalServer + } + return &dto.UpdateUserResp{}, nil } func (s *UserService) generate2Token(userID uint) (string, string, error) { - token := utils.Jwt.NewClaims(userID, "", false, time.Duration(utils.Env.GetAsInt(constant.EnvKeyTokenDuration, constant.EnvKeyTokenDurationDefault))*time.Second) - tokenString, err := token.ToString() - if err != nil { - return "", "", errs.ErrInternalServer - } - refreshToken := utils.Jwt.NewClaims(userID, utils.Strings.GenerateRandomString(64), true, time.Duration(utils.Env.GetAsInt(constant.EnvKeyRefreshTokenDuration, constant.EnvKeyRefreshTokenDurationDefault))*time.Second) - refreshTokenString, err := refreshToken.ToString() - if err != nil { - return "", "", errs.ErrInternalServer - } - err = repo.Session.SaveSession(refreshToken.SessionKey) - if err != nil { - return "", "", errs.ErrInternalServer - } - return tokenString, refreshTokenString, nil + token := utils.Jwt.NewClaims(userID, "", false, time.Duration(utils.Env.GetAsInt(constant.EnvKeyTokenDuration, constant.EnvKeyTokenDurationDefault))*time.Second) + tokenString, err := token.ToString() + if err != nil { + return "", "", errs.ErrInternalServer + } + refreshToken := utils.Jwt.NewClaims(userID, utils.Strings.GenerateRandomString(64), true, time.Duration(utils.Env.GetAsInt(constant.EnvKeyRefreshTokenDuration, constant.EnvKeyRefreshTokenDurationDefault))*time.Second) + refreshTokenString, err := refreshToken.ToString() + if err != nil { + return "", "", errs.ErrInternalServer + } + err = repo.Session.SaveSession(refreshToken.SessionKey) + if err != nil { + return "", "", errs.ErrInternalServer + } + return tokenString, refreshTokenString, nil } func (s *UserService) verifyEmail(email, code string) (bool, error) { - kv := utils.KV.GetInstance() - verificationCode, ok := kv.Get(constant.KVKeyEmailVerificationCode + email) - if !ok || verificationCode != code { - return false, errs.New(http.StatusForbidden, "Invalid email verification code", nil) - } - return true, nil + kv := utils.KV.GetInstance() + verificationCode, ok := kv.Get(constant.KVKeyEmailVerificationCode + email) + if !ok || verificationCode != code { + return false, errs.New(http.StatusForbidden, "Invalid email verification code", nil) + } + return true, nil } diff --git a/pkg/constant/constant.go b/pkg/constant/constant.go index e9086ca..31c3563 100644 --- a/pkg/constant/constant.go +++ b/pkg/constant/constant.go @@ -10,15 +10,19 @@ const ( ModeProd = "prod" RoleUser = "user" RoleAdmin = "admin" - EnvKeyBaseUrl = "BASE_URL" // 环境变量:基础URL - EnvKeyLogLevel = "LOG_LEVEL" // 环境变量:日志级别 - EnvKeyMode = "MODE" // 环境变量:运行模式 - EnvKeyJwtSecrete = "JWT_SECRET" // 环境变量:JWT密钥 - EnvKeyPasswordSalt = "PASSWORD_SALT" // 环境变量:密码盐 - EnvKeyTokenDuration = "TOKEN_DURATION" // 环境变量:令牌有效期 - EnvKeyMaxReplyDepth = "MAX_REPLY_DEPTH" // 环境变量:最大回复深度 - EnvKeyTokenDurationDefault = 300 - EnvKeyRefreshTokenDurationDefault = 604800 + EnvKeyBaseUrl = "BASE_URL" // 环境变量:基础URL + EnvKeyCaptchaProvider = "CAPTCHA_PROVIDER" // captcha提供者 + EnvKeyCaptchaSecreteKey = "CAPTCHA_SECRET_KEY" // captcha站点密钥 + EnvKeyCaptchaUrl = "CAPTCHA_URL" // 某些自托管的captcha的url + EnvKeyCaptchaSiteKey = "CAPTCHA_SITE_KEY" // captcha密钥key + EnvKeyLogLevel = "LOG_LEVEL" // 环境变量:日志级别 + EnvKeyMode = "MODE" // 环境变量:运行模式 + EnvKeyJwtSecrete = "JWT_SECRET" // 环境变量:JWT密钥 + EnvKeyPasswordSalt = "PASSWORD_SALT" // 环境变量:密码盐 + EnvKeyTokenDuration = "TOKEN_DURATION" // 环境变量:令牌有效期 + EnvKeyMaxReplyDepth = "MAX_REPLY_DEPTH" // 环境变量:最大回复深度 + EnvKeyTokenDurationDefault = 300 // Token有效时长 + EnvKeyRefreshTokenDurationDefault = 604800 // refresh token有效时长 EnvKeyRefreshTokenDuration = "REFRESH_TOKEN_DURATION" // 环境变量:刷新令牌有效期 EnvKeyRefreshTokenDurationWithRemember = "REFRESH_TOKEN_DURATION_WITH_REMEMBER" // 环境变量:记住我刷新令牌有效期 KVKeyEmailVerificationCode = "email_verification_code:" // KV存储:邮箱验证码 diff --git a/pkg/utils/captcha.go b/pkg/utils/captcha.go index 040c167..02d6ac8 100644 --- a/pkg/utils/captcha.go +++ b/pkg/utils/captcha.go @@ -12,16 +12,16 @@ type captchaUtils struct{} var Captcha = captchaUtils{} type CaptchaConfig struct { - Type string - SiteSecret string // Site secret key for the captcha service - SecretKey string // Secret key for the captcha service + Type string + SiteKey string // Site secret key for the captcha service + SecretKey string // Secret key for the captcha service } func (c *captchaUtils) GetCaptchaConfigFromEnv() *CaptchaConfig { return &CaptchaConfig{ - Type: Env.Get("CAPTCHA_TYPE", "disable"), - SiteSecret: Env.Get("CAPTCHA_SITE_SECRET", ""), - SecretKey: Env.Get("CAPTCHA_SECRET_KEY", ""), + Type: Env.Get(constant.EnvKeyCaptchaProvider, constant.CaptchaTypeDisable), + SiteKey: Env.Get(constant.EnvKeyCaptchaSiteKey, ""), + SecretKey: Env.Get(constant.EnvKeyCaptchaSecreteKey, ""), } } diff --git a/web/package.json b/web/package.json index 55cd934..24457c7 100644 --- a/web/package.json +++ b/web/package.json @@ -9,6 +9,8 @@ "lint": "next lint" }, "dependencies": { + "@hcaptcha/react-hcaptcha": "^1.12.1", + "@marsidev/react-turnstile": "^1.3.0", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", @@ -30,6 +32,7 @@ "next-themes": "^0.4.6", "react": "19.1.0", "react-dom": "19.1.0", + "react-google-recaptcha-v3": "^1.11.0", "react-icons": "^5.5.0", "rehype-highlight": "^7.0.2", "sonner": "^2.0.6", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 0a9c996..290d6a0 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@hcaptcha/react-hcaptcha': + specifier: ^1.12.1 + version: 1.12.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@marsidev/react-turnstile': + specifier: ^1.3.0 + version: 1.3.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-checkbox': specifier: ^1.3.3 version: 1.3.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -71,6 +77,9 @@ importers: react-dom: specifier: 19.1.0 version: 19.1.0(react@19.1.0) + react-google-recaptcha-v3: + specifier: ^1.11.0 + version: 1.11.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react-icons: specifier: ^5.5.0 version: 5.5.0(react@19.1.0) @@ -130,6 +139,10 @@ packages: resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + '@emnapi/core@1.4.4': resolution: {integrity: sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==} @@ -195,6 +208,15 @@ packages: '@formatjs/intl-localematcher@0.6.1': resolution: {integrity: sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==} + '@hcaptcha/loader@2.0.1': + resolution: {integrity: sha512-L36qqdOmv8fL6VBZcH34JUI0/SvC5KPOZ5N/m+5pQAPPhtXXRdU4o9iosZr12hWAM2qf5hC92kmi+XdqxKOEZQ==} + + '@hcaptcha/react-hcaptcha@1.12.1': + resolution: {integrity: sha512-/A08MOAHa5L9B8UfNRkTR/+x2dOyfk3pI1/qgXI4NpDl/z4CjnSxaYCDtkbD21vEocN1KKCggQD3wJ7OcY494w==} + peerDependencies: + react: '>= 16.3.0' + react-dom: '>= 16.3.0' + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -354,6 +376,12 @@ packages: '@jridgewell/trace-mapping@0.3.29': resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} + '@marsidev/react-turnstile@1.3.0': + resolution: {integrity: sha512-VO99Nynt+j4ETfMImQCj5LgbUKZ9mWPpy3RjP/3e/3vZu+FIphjEdU6g+cq4FeDoNshSxLlRzBTKcH5JMeM1GQ==} + peerDependencies: + react: ^17.0.2 || ^18.0.0 || ^19.0 + react-dom: ^17.0.2 || ^18.0.0 || ^19.0 + '@mdx-js/mdx@3.1.0': resolution: {integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==} @@ -1689,6 +1717,9 @@ packages: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -2320,6 +2351,12 @@ packages: peerDependencies: react: ^19.1.0 + react-google-recaptcha-v3@1.11.0: + resolution: {integrity: sha512-kLQqpz/77m8+trpBwzqcxNtvWZYoZ/YO6Vm2cVTHW8hs80BWUfDpC7RDwuAvpswwtSYApWfaSpIDFWAIBNIYxQ==} + peerDependencies: + react: ^16.3 || ^17.0 || ^18.0 || ^19.0 + react-dom: ^17.0 || ^18.0 || ^19.0 + react-icons@5.5.0: resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==} peerDependencies: @@ -2788,6 +2825,8 @@ snapshots: '@babel/helper-validator-identifier@7.27.1': {} + '@babel/runtime@7.28.4': {} + '@emnapi/core@1.4.4': dependencies: '@emnapi/wasi-threads': 1.0.3 @@ -2878,6 +2917,15 @@ snapshots: dependencies: tslib: 2.8.1 + '@hcaptcha/loader@2.0.1': {} + + '@hcaptcha/react-hcaptcha@1.12.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@babel/runtime': 7.28.4 + '@hcaptcha/loader': 2.0.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -2995,6 +3043,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.4 + '@marsidev/react-turnstile@1.3.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + '@mdx-js/mdx@3.1.0(acorn@8.15.0)': dependencies: '@types/estree': 1.0.8 @@ -4499,6 +4552,10 @@ snapshots: highlight.js@11.11.1: {} + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -5313,6 +5370,12 @@ snapshots: react: 19.1.0 scheduler: 0.26.0 + react-google-recaptcha-v3@1.11.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + hoist-non-react-statics: 3.3.2 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-icons@5.5.0(react@19.1.0): dependencies: react: 19.1.0 diff --git a/web/src/api/user.ts b/web/src/api/user.ts index f5cb649..bde82d2 100644 --- a/web/src/api/user.ts +++ b/web/src/api/user.ts @@ -1,14 +1,26 @@ import type { OidcConfig } from '@/models/oidc-config' import type { BaseResponse } from '@/models/resp' -import type { LoginRequest, RegisterRequest, User } from '@/models/user' +import type { RegisterRequest, User } from '@/models/user' import axiosClient from './client' +import { CaptchaProvider } from '@/models/captcha' export async function userLogin( - data: LoginRequest, -): Promise> { + { + username, + password, + rememberMe, + captcha + }: { + username: string, + password: string, + rememberMe?: boolean, + captcha?: string, + }): Promise> { + console.log("Logging in with captcha:", captcha) const res = await axiosClient.post>( '/user/login', - data, + { username, password, rememberMe }, + { headers: { 'X-Captcha-Token': captcha || '' } }, ) return res.data } @@ -43,3 +55,21 @@ export async function getUserById(id: number): Promise> { const res = await axiosClient.get>(`/user/u/${id}`) return res.data } + +export async function getUserByUsername(username: string): Promise> { + const res = await axiosClient.get>(`/user/username/${username}`) + return res.data +} + +export async function getCaptchaConfig(): Promise> { + const res = await axiosClient.get>('/user/captcha') + return res.data +} \ No newline at end of file diff --git a/web/src/app/(main)/layout.tsx b/web/src/app/(main)/layout.tsx index 374a8bf..71ec3bf 100644 --- a/web/src/app/(main)/layout.tsx +++ b/web/src/app/(main)/layout.tsx @@ -5,6 +5,7 @@ import { usePathname } from 'next/navigation' import { Navbar } from '@/components/layout/navbar' import { BackgroundProvider } from '@/contexts/background-context' import Footer from '@/components/layout/footer' +import config from '@/config' export default function RootLayout({ children, @@ -14,25 +15,18 @@ export default function RootLayout({ const pathname = usePathname() return ( <> -
+ +
- - - -
{children}
-
-
+
+ + +
{children}
+