mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-03 15:56:22 +00:00
⚡ implement email verification feature, add captcha validation middleware, and enhance user authentication flow
This commit is contained in:
7
go.mod
7
go.mod
@ -5,10 +5,14 @@ go 1.23.3
|
|||||||
require (
|
require (
|
||||||
github.com/cloudwego/hertz v0.10.1
|
github.com/cloudwego/hertz v0.10.1
|
||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.3
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/sirupsen/logrus v1.9.3
|
github.com/sirupsen/logrus v1.9.3
|
||||||
|
golang.org/x/crypto v0.31.0
|
||||||
|
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/gorm v1.30.0
|
gorm.io/gorm v1.30.0
|
||||||
|
resty.dev/v3 v3.0.0-beta.3
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@ -38,15 +42,14 @@ require (
|
|||||||
github.com/tidwall/pretty v1.2.0 // indirect
|
github.com/tidwall/pretty v1.2.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
|
||||||
golang.org/x/crypto v0.31.0 // indirect
|
|
||||||
golang.org/x/net v0.33.0 // indirect
|
golang.org/x/net v0.33.0 // indirect
|
||||||
golang.org/x/sync v0.10.0 // indirect
|
golang.org/x/sync v0.10.0 // indirect
|
||||||
golang.org/x/sys v0.28.0 // indirect
|
golang.org/x/sys v0.28.0 // indirect
|
||||||
golang.org/x/text v0.21.0 // indirect
|
golang.org/x/text v0.21.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.1 // indirect
|
google.golang.org/protobuf v1.34.1 // indirect
|
||||||
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||||
modernc.org/libc v1.22.5 // indirect
|
modernc.org/libc v1.22.5 // indirect
|
||||||
modernc.org/mathutil v1.5.0 // indirect
|
modernc.org/mathutil v1.5.0 // indirect
|
||||||
modernc.org/memory v1.5.0 // indirect
|
modernc.org/memory v1.5.0 // indirect
|
||||||
modernc.org/sqlite v1.23.1 // indirect
|
modernc.org/sqlite v1.23.1 // indirect
|
||||||
resty.dev/v3 v3.0.0-beta.3 // indirect
|
|
||||||
)
|
)
|
||||||
|
6
go.sum
6
go.sum
@ -26,6 +26,8 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g
|
|||||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4=
|
github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
@ -154,8 +156,12 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T
|
|||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||||
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||||
|
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
|
||||||
|
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
@ -3,26 +3,66 @@ package v1
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"github.com/cloudwego/hertz/pkg/app"
|
"github.com/cloudwego/hertz/pkg/app"
|
||||||
|
"github.com/cloudwego/hertz/pkg/protocol"
|
||||||
"github.com/snowykami/neo-blog/internal/dto"
|
"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"
|
"github.com/snowykami/neo-blog/pkg/resps"
|
||||||
|
"github.com/snowykami/neo-blog/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type userType struct{}
|
type userType struct {
|
||||||
|
service service.UserService
|
||||||
|
}
|
||||||
|
|
||||||
var User = new(userType)
|
var User = &userType{
|
||||||
|
service: service.NewUserService(),
|
||||||
|
}
|
||||||
|
|
||||||
func (u *userType) Login(ctx context.Context, c *app.RequestContext) {
|
func (u *userType) Login(ctx context.Context, c *app.RequestContext) {
|
||||||
var userLoginReq dto.UserLoginReq
|
var userLoginReq *dto.UserLoginReq
|
||||||
if err := c.BindAndValidate(&userLoginReq); err != nil {
|
if err := c.BindAndValidate(userLoginReq); err != nil {
|
||||||
resps.BadRequest(c, resps.ErrParamInvalid)
|
resps.BadRequest(c, resps.ErrParamInvalid)
|
||||||
}
|
}
|
||||||
|
resp, err := u.service.UserLogin(userLoginReq)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
serviceErr := errs.AsServiceError(err)
|
||||||
|
resps.Custom(c, serviceErr.Code, serviceErr.Message, nil)
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
resps.UnAuthorized(c, resps.ErrInvalidCredentials)
|
||||||
|
} else {
|
||||||
|
u.setTokenCookie(c, resp.Token, resp.RefreshToken)
|
||||||
|
resps.Ok(c, resps.Success, resp)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *userType) Register(ctx context.Context, c *app.RequestContext) {
|
func (u *userType) Register(ctx context.Context, c *app.RequestContext) {
|
||||||
|
var userRegisterReq *dto.UserRegisterReq
|
||||||
|
if err := c.BindAndValidate(userRegisterReq); err != nil {
|
||||||
|
resps.BadRequest(c, resps.ErrParamInvalid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := u.service.UserRegister(userRegisterReq)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
serviceErr := errs.AsServiceError(err)
|
||||||
|
resps.Custom(c, serviceErr.Code, serviceErr.Message, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
resps.UnAuthorized(c, resps.ErrInvalidCredentials)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.setTokenCookie(c, resp.Token, resp.RefreshToken)
|
||||||
|
resps.Ok(c, resps.Success, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *userType) Logout(ctx context.Context, c *app.RequestContext) {
|
func (u *userType) Logout(ctx context.Context, c *app.RequestContext) {
|
||||||
// TODO: Impl
|
u.clearTokenCookie(c)
|
||||||
|
resps.Ok(c, resps.Success, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *userType) OidcList(ctx context.Context, c *app.RequestContext) {
|
func (u *userType) OidcList(ctx context.Context, c *app.RequestContext) {
|
||||||
@ -44,3 +84,28 @@ func (u *userType) Update(ctx context.Context, c *app.RequestContext) {
|
|||||||
func (u *userType) Delete(ctx context.Context, c *app.RequestContext) {
|
func (u *userType) Delete(ctx context.Context, c *app.RequestContext) {
|
||||||
// TODO: Impl
|
// TODO: Impl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *userType) VerifyEmail(ctx context.Context, c *app.RequestContext) {
|
||||||
|
var verifyEmailReq *dto.VerifyEmailReq
|
||||||
|
if err := c.BindAndValidate(verifyEmailReq); err != nil {
|
||||||
|
resps.BadRequest(c, resps.ErrParamInvalid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := u.service.VerifyEmail(verifyEmailReq)
|
||||||
|
if err != nil {
|
||||||
|
serviceErr := errs.AsServiceError(err)
|
||||||
|
resps.Custom(c, serviceErr.Code, serviceErr.Message, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resps.Ok(c, resps.Success, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *userType) setTokenCookie(c *app.RequestContext, token, refreshToken string) {
|
||||||
|
c.SetCookie("token", token, utils.Env.GetenvAsInt(constant.EnvKeyTokenDuration, constant.EnvKeyTokenDurationDefault), "/", "", protocol.CookieSameSiteLaxMode, true, true)
|
||||||
|
c.SetCookie("refresh_token", refreshToken, -1, "/", "", protocol.CookieSameSiteLaxMode, true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *userType) clearTokenCookie(c *app.RequestContext) {
|
||||||
|
c.SetCookie("token", "", -1, "/", "", protocol.CookieSameSiteLaxMode, true, true)
|
||||||
|
c.SetCookie("refresh_token", "", -1, "/", "", protocol.CookieSameSiteLaxMode, true, true)
|
||||||
|
}
|
||||||
|
@ -14,9 +14,9 @@ type UserLoginReq struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserLoginResp struct {
|
type UserLoginResp struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
RefreshToken string `json:"refresh_token"`
|
RefreshToken string `json:"refresh_token"`
|
||||||
User UserDto `json:"user"`
|
User *UserDto `json:"user"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserRegisterReq struct {
|
type UserRegisterReq struct {
|
||||||
@ -31,3 +31,11 @@ type UserRegisterResp struct {
|
|||||||
RefreshToken string `json:"refresh_token"` // 刷新令牌
|
RefreshToken string `json:"refresh_token"` // 刷新令牌
|
||||||
User UserDto `json:"user"` // 用户信息
|
User UserDto `json:"user"` // 用户信息
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VerifyEmailReq struct {
|
||||||
|
Email string `json:"email"` // 邮箱地址
|
||||||
|
}
|
||||||
|
|
||||||
|
type VerifyEmailResp struct {
|
||||||
|
Success bool `json:"success"` // 验证码发送成功与否
|
||||||
|
}
|
||||||
|
@ -3,10 +3,35 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"github.com/cloudwego/hertz/pkg/app"
|
"github.com/cloudwego/hertz/pkg/app"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/snowykami/neo-blog/pkg/resps"
|
||||||
|
"github.com/snowykami/neo-blog/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// UseCaptcha 中间件函数,用于X-Captcha-Token验证码
|
||||||
func UseCaptcha() app.HandlerFunc {
|
func UseCaptcha() app.HandlerFunc {
|
||||||
|
captchaConfig := utils.Captcha.GetCaptchaConfigFromEnv()
|
||||||
return func(ctx context.Context, c *app.RequestContext) {
|
return func(ctx context.Context, c *app.RequestContext) {
|
||||||
// TODO: Implement captcha validation logic here
|
CaptchaToken := string(c.GetHeader("X-Captcha-Token"))
|
||||||
|
if utils.IsDevMode && CaptchaToken == utils.Env.Get("CAPTCHA_DEV_PASSCODE", "dev_passcode") {
|
||||||
|
// 开发模式直接通过密钥
|
||||||
|
c.Next(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok, err := utils.Captcha.VerifyCaptcha(captchaConfig, CaptchaToken)
|
||||||
|
if err != nil {
|
||||||
|
logrus.Error("Captcha verification error:", err)
|
||||||
|
resps.InternalServerError(c, "Captcha verification failed")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
logrus.Warn("Captcha verification failed for token:", CaptchaToken)
|
||||||
|
resps.Forbidden(c, "Captcha verification failed")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next(ctx) // 如果验证码验证成功,则继续下一个处理程序
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
8
internal/model/session.go
Normal file
8
internal/model/session.go
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "gorm.io/gorm"
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
gorm.Model
|
||||||
|
SessionKey string `gorm:"uniqueIndex"` // 会话密钥,唯一索引
|
||||||
|
}
|
@ -1,6 +1,9 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import "gorm.io/gorm"
|
import (
|
||||||
|
"github.com/snowykami/neo-blog/internal/dto"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
@ -13,3 +16,14 @@ type User struct {
|
|||||||
|
|
||||||
Password string // 密码,存储加密后的值
|
Password string // 密码,存储加密后的值
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (user *User) ToDto() *dto.UserDto {
|
||||||
|
return &dto.UserDto{
|
||||||
|
Username: user.Username,
|
||||||
|
Nickname: user.Nickname,
|
||||||
|
AvatarUrl: user.AvatarUrl,
|
||||||
|
Email: user.Email,
|
||||||
|
Gender: user.Gender,
|
||||||
|
Role: user.Role,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
23
internal/repo/session.go
Normal file
23
internal/repo/session.go
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import "github.com/snowykami/neo-blog/internal/model"
|
||||||
|
|
||||||
|
type sessionRepo struct{}
|
||||||
|
|
||||||
|
var Session = sessionRepo{}
|
||||||
|
|
||||||
|
func (s *sessionRepo) SaveSession(sessionKey string) error {
|
||||||
|
session := &model.Session{
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
}
|
||||||
|
return db.Create(session).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sessionRepo) IsSessionValid(sessionKey string) (bool, error) {
|
||||||
|
var count int64
|
||||||
|
err := db.Model(&model.Session{}).Where("session_key = ?", sessionKey).Count(&count).Error
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
@ -13,6 +13,7 @@ func registerUserRoutes(group *route.RouterGroup) {
|
|||||||
{
|
{
|
||||||
userGroupWithoutAuthNeedsCaptcha.POST("/login", v1.User.Login)
|
userGroupWithoutAuthNeedsCaptcha.POST("/login", v1.User.Login)
|
||||||
userGroupWithoutAuthNeedsCaptcha.POST("/register", v1.User.Register)
|
userGroupWithoutAuthNeedsCaptcha.POST("/register", v1.User.Register)
|
||||||
|
userGroupWithoutAuthNeedsCaptcha.POST("/email/verify", v1.User.VerifyEmail) // Send email verification code
|
||||||
userGroupWithoutAuth.GET("/oidc/list", v1.User.OidcList)
|
userGroupWithoutAuth.GET("/oidc/list", v1.User.OidcList)
|
||||||
userGroupWithoutAuth.GET("/oidc/login/:name", v1.User.OidcLogin)
|
userGroupWithoutAuth.GET("/oidc/login/:name", v1.User.OidcLogin)
|
||||||
userGroupWithoutAuth.GET("/u/:id", v1.User.Get)
|
userGroupWithoutAuth.GET("/u/:id", v1.User.Get)
|
||||||
|
@ -1,25 +1,22 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"github.com/cloudwego/hertz/pkg/app/server"
|
"github.com/cloudwego/hertz/pkg/app/server"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/snowykami/neo-blog/internal/router/apiv1"
|
"github.com/snowykami/neo-blog/internal/router/apiv1"
|
||||||
"github.com/snowykami/neo-blog/pkg/constant"
|
|
||||||
"github.com/snowykami/neo-blog/pkg/utils"
|
"github.com/snowykami/neo-blog/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
var h *server.Hertz
|
var h *server.Hertz
|
||||||
|
|
||||||
func Run() error {
|
func Run() error {
|
||||||
mode := utils.Env.Get("MODE", constant.ModeProd) // dev | prod
|
if utils.IsDevMode {
|
||||||
switch mode {
|
logrus.Infoln("Running in development mode")
|
||||||
case constant.ModeProd:
|
return h.Run()
|
||||||
|
} else {
|
||||||
|
logrus.Infoln("Running in production mode")
|
||||||
h.Spin()
|
h.Spin()
|
||||||
return nil
|
return nil
|
||||||
case constant.ModeDev:
|
|
||||||
return h.Run()
|
|
||||||
default:
|
|
||||||
return errors.New("unknown mode: " + mode)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,16 +1,20 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/snowykami/neo-blog/internal/dto"
|
"github.com/snowykami/neo-blog/internal/dto"
|
||||||
"github.com/snowykami/neo-blog/internal/repo"
|
"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/constant"
|
||||||
"github.com/snowykami/neo-blog/pkg/resps"
|
"github.com/snowykami/neo-blog/pkg/errs"
|
||||||
"github.com/snowykami/neo-blog/pkg/utils"
|
"github.com/snowykami/neo-blog/pkg/utils"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserService interface {
|
type UserService interface {
|
||||||
UserLogin(dto *dto.UserLoginReq) (*dto.UserLoginResp, error)
|
UserLogin(*dto.UserLoginReq) (*dto.UserLoginResp, error)
|
||||||
|
UserRegister(*dto.UserRegisterReq) (*dto.UserRegisterResp, error)
|
||||||
|
VerifyEmail(*dto.VerifyEmailReq) (*dto.VerifyEmailResp, error)
|
||||||
// TODO impl other user-related methods
|
// TODO impl other user-related methods
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -20,17 +24,63 @@ func NewUserService() UserService {
|
|||||||
return &userService{}
|
return &userService{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *userService) UserLogin(dto *dto.UserLoginReq) (*dto.UserLoginResp, error) {
|
func (s *userService) UserLogin(req *dto.UserLoginReq) (*dto.UserLoginResp, error) {
|
||||||
user, err := repo.User.GetByUsernameOrEmail(dto.Username)
|
user, err := repo.User.GetByUsernameOrEmail(req.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil, errors.New(resps.ErrNotFound)
|
return nil, errs.ErrNotFound
|
||||||
}
|
}
|
||||||
if utils.Password.VerifyPassword(dto.Password, user.Password, utils.Env.Get(constant.EnvVarPasswordSalt, "default_salt")) {
|
if utils.Password.VerifyPassword(req.Password, user.Password, utils.Env.Get(constant.EnvKeyPasswordSalt, "default_salt")) {
|
||||||
return nil, nil // TODO: Generate JWT token and return it in the response
|
|
||||||
|
token := utils.Jwt.NewClaims(user.ID, "", false, time.Duration(utils.Env.GetenvAsInt(constant.EnvKeyTokenDuration, 24)*int(time.Hour)))
|
||||||
|
tokenString, err := token.ToString()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.ErrInternalServer
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken := utils.Jwt.NewClaims(user.ID, utils.Strings.GenerateRandomString(64), true, time.Duration(utils.Env.GetenvAsInt(constant.EnvKeyRefreshTokenDuration, 30)*int(time.Hour)))
|
||||||
|
refreshTokenString, err := refreshToken.ToString()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.ErrInternalServer
|
||||||
|
}
|
||||||
|
// 对refresh token进行持久化存储
|
||||||
|
err = repo.Session.SaveSession(refreshToken.SessionKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.ErrInternalServer
|
||||||
|
}
|
||||||
|
resp := &dto.UserLoginResp{
|
||||||
|
Token: tokenString,
|
||||||
|
RefreshToken: refreshTokenString,
|
||||||
|
User: user.ToDto(),
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
} else {
|
} else {
|
||||||
return nil, errors.New(resps.ErrInvalidCredentials)
|
return nil, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *userService) UserRegister(req *dto.UserRegisterReq) (*dto.UserRegisterResp, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *userService) VerifyEmail(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)
|
||||||
|
|
||||||
|
template, err := static.RenderTemplate("email/verification-code.tmpl", map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.ErrInternalServer
|
||||||
|
}
|
||||||
|
if utils.IsDevMode {
|
||||||
|
logrus.Infoln("%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
|
||||||
|
}
|
||||||
|
70
internal/static/assets/email/verification-code.tmpl
Normal file
70
internal/static/assets/email/verification-code.tmpl
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 20px auto;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.header h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #007BFF;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.content p {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.code {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #007BFF;
|
||||||
|
text-align: center;
|
||||||
|
margin: 20px 0;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #f0f8ff;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>欢迎使用 {{.Title}}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>尊敬的用户 {{.Email}},您好!</p>
|
||||||
|
<p>{{.Details}} 以下是您的验证码:</p>
|
||||||
|
<div class="code">{{.VerifyCode}}</div>
|
||||||
|
<p>请在 <strong>{{.Expire}}</strong> 分钟内使用此验证码完成验证。</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>如果您未请求此邮件,请忽略。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
31
internal/static/embed.go
Normal file
31
internal/static/embed.go
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
package static
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed assets/*
|
||||||
|
var AssetsFS embed.FS
|
||||||
|
|
||||||
|
// RenderTemplate 从嵌入的文件系统中读取模板并渲染
|
||||||
|
func RenderTemplate(name string, data interface{}) (string, error) {
|
||||||
|
templatePath := "assets/" + name
|
||||||
|
templateContent, err := AssetsFS.ReadFile(templatePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("读取模板文件失败: %w", err)
|
||||||
|
}
|
||||||
|
// 解析模板
|
||||||
|
tmpl, err := template.New(name).Parse(string(templateContent))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("解析模板失败: %w", err)
|
||||||
|
}
|
||||||
|
// 渲染模板
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, data); err != nil {
|
||||||
|
return "", fmt.Errorf("渲染模板失败: %w", err)
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
18
internal/static/embed_test.go
Normal file
18
internal/static/embed_test.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package static
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderTemplate(t *testing.T) {
|
||||||
|
template, err := RenderTemplate("email/verification-code.tmpl", map[string]interface{}{
|
||||||
|
"Title": "Test Page",
|
||||||
|
"Email": "xxx@.comcom",
|
||||||
|
"Details": "nihao",
|
||||||
|
})
|
||||||
|
t.Logf(template)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("渲染模板失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
@ -1,10 +1,22 @@
|
|||||||
package constant
|
package constant
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ModeDev = "dev"
|
CaptchaTypeDisable = "disable" // 禁用验证码
|
||||||
ModeProd = "prod"
|
CaptchaTypeHCaptcha = "hcaptcha" // HCaptcha验证码
|
||||||
RoleUser = "user"
|
CaptchaTypeTurnstile = "turnstile" // Turnstile验证码
|
||||||
RoleAdmin = "admin"
|
CaptchaTypeReCaptcha = "recaptcha" // ReCaptcha验证码
|
||||||
|
ModeDev = "dev"
|
||||||
|
ModeProd = "prod"
|
||||||
|
RoleUser = "user"
|
||||||
|
RoleAdmin = "admin"
|
||||||
|
|
||||||
EnvVarPasswordSalt = "PASSWORD_SALT" // 环境变量:密码盐
|
EnvKeyMode = "MODE" // 环境变量:运行模式
|
||||||
|
EnvKeyJwtSecrete = "JWT_SECRET" // 环境变量:JWT密钥
|
||||||
|
EnvKeyPasswordSalt = "PASSWORD_SALT" // 环境变量:密码盐
|
||||||
|
EnvKeyTokenDuration = "TOKEN_DURATION" // 环境变量:令牌有效期
|
||||||
|
EnvKeyTokenDurationDefault = 300
|
||||||
|
EnvKeyRefreshTokenDuration = "REFRESH_TOKEN_DURATION" // 环境变量:刷新令牌有效期
|
||||||
|
EnvKeyRefreshTokenDurationWithRemember = "REFRESH_TOKEN_DURATION_WITH_REMEMBER" // 环境变量:记住我刷新令牌有效期
|
||||||
|
|
||||||
|
KVKeyEmailVerificationCode = "email_verification_code" // KV存储:邮箱验证码
|
||||||
)
|
)
|
||||||
|
58
pkg/errs/errors.go
Normal file
58
pkg/errs/errors.go
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
package errs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"github.com/cloudwego/hertz/pkg/app"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServiceError 业务错误结构
|
||||||
|
type ServiceError struct {
|
||||||
|
Code int // 错误代码
|
||||||
|
Message string // 错误消息
|
||||||
|
Err error // 原始错误
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ServiceError) Error() string {
|
||||||
|
if e.Err != nil {
|
||||||
|
return e.Message + ": " + e.Err.Error()
|
||||||
|
}
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// 常见业务错误
|
||||||
|
var (
|
||||||
|
ErrNotFound = &ServiceError{Code: http.StatusNotFound, Message: "not found"}
|
||||||
|
ErrInvalidCredentials = &ServiceError{Code: http.StatusUnauthorized, Message: "invalid credentials"}
|
||||||
|
ErrInternalServer = &ServiceError{Code: http.StatusInternalServerError, Message: "internal server error"}
|
||||||
|
ErrBadRequest = &ServiceError{Code: http.StatusBadRequest, Message: "invalid request parameters"}
|
||||||
|
ErrForbidden = &ServiceError{Code: http.StatusForbidden, Message: "access forbidden"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// New 创建自定义错误
|
||||||
|
func New(code int, message string, err error) *ServiceError {
|
||||||
|
return &ServiceError{
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
Err: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is 判断错误类型
|
||||||
|
func Is(err, target error) bool {
|
||||||
|
return errors.Is(err, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AsServiceError 将错误转换为ServiceError
|
||||||
|
func AsServiceError(err error) *ServiceError {
|
||||||
|
var serviceErr *ServiceError
|
||||||
|
if errors.As(err, &serviceErr) {
|
||||||
|
return serviceErr
|
||||||
|
}
|
||||||
|
return ErrInternalServer
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleError 处理错误并返回HTTP状态码和消息
|
||||||
|
func HandleError(c *app.RequestContext, err *ServiceError) {
|
||||||
|
|
||||||
|
}
|
19
pkg/errs/errors_test.go
Normal file
19
pkg/errs/errors_test.go
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
package errs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAsServiceError(c) {
|
||||||
|
serviceError := ErrNotFound
|
||||||
|
err := AsServiceError(serviceError)
|
||||||
|
if err.Code != serviceError.Code || err.Message != serviceError.Message {
|
||||||
|
t.Errorf("Expected %v, got %v", serviceError, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceError = New(520, "Custom error", nil)
|
||||||
|
err = AsServiceError(serviceError)
|
||||||
|
if err.Code != serviceError.Code || err.Message != serviceError.Message {
|
||||||
|
t.Errorf("Expected %v, got %v", serviceError, err)
|
||||||
|
}
|
||||||
|
}
|
@ -1,10 +1,12 @@
|
|||||||
package resps
|
package resps
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ErrParamInvalid = "invalid request parameters"
|
Success = "success"
|
||||||
ErrUnauthorized = "unauthorized access"
|
ErrParamInvalid = "invalid request parameters"
|
||||||
ErrForbidden = "access forbidden"
|
ErrUnauthorized = "unauthorized access"
|
||||||
ErrNotFound = "resource not found"
|
ErrForbidden = "access forbidden"
|
||||||
|
ErrNotFound = "resource not found"
|
||||||
|
ErrInternalServerError = "internal server error"
|
||||||
|
|
||||||
ErrInvalidCredentials = "invalid credentials"
|
ErrInvalidCredentials = "invalid credentials"
|
||||||
)
|
)
|
||||||
|
93
pkg/utils/captcha.go
Normal file
93
pkg/utils/captcha.go
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/snowykami/neo-blog/pkg/constant"
|
||||||
|
"resty.dev/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyCaptcha 根据提供的配置和令牌验证验证码
|
||||||
|
func (c *captchaUtils) VerifyCaptcha(captchaConfig *CaptchaConfig, captchaToken string) (bool, error) {
|
||||||
|
restyClient := resty.New()
|
||||||
|
switch captchaConfig.Type {
|
||||||
|
case constant.CaptchaTypeDisable:
|
||||||
|
return true, nil
|
||||||
|
case constant.CaptchaTypeHCaptcha:
|
||||||
|
result := make(map[string]any)
|
||||||
|
resp, err := restyClient.R().
|
||||||
|
SetFormData(map[string]string{
|
||||||
|
"secret": captchaConfig.SecretKey,
|
||||||
|
"response": captchaToken,
|
||||||
|
}).SetResult(&result).Post("https://hcaptcha.com/siteverify")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if resp.IsError() {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
fmt.Printf("%#v\n", result)
|
||||||
|
if success, ok := result["success"].(bool); ok && success {
|
||||||
|
return true, nil
|
||||||
|
} else {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
case constant.CaptchaTypeTurnstile:
|
||||||
|
result := make(map[string]any)
|
||||||
|
resp, err := restyClient.R().
|
||||||
|
SetFormData(map[string]string{
|
||||||
|
"secret": captchaConfig.SecretKey,
|
||||||
|
"response": captchaToken,
|
||||||
|
}).SetResult(&result).Post("https://challenges.cloudflare.com/turnstile/v0/siteverify")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if resp.IsError() {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
fmt.Printf("%#v\n", result)
|
||||||
|
if success, ok := result["success"].(bool); ok && success {
|
||||||
|
return true, nil
|
||||||
|
} else {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
case constant.CaptchaTypeReCaptcha:
|
||||||
|
result := make(map[string]any)
|
||||||
|
resp, err := restyClient.R().
|
||||||
|
SetFormData(map[string]string{
|
||||||
|
"secret": captchaConfig.SecretKey,
|
||||||
|
"response": captchaToken,
|
||||||
|
}).SetResult(&result).Post("https://www.google.com/recaptcha/api/siteverify")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if resp.IsError() {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
fmt.Printf("%#v\n", result)
|
||||||
|
if success, ok := result["success"].(bool); ok && success {
|
||||||
|
return true, nil
|
||||||
|
} else {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("invalid captcha type: %s", captchaConfig.Type)
|
||||||
|
}
|
||||||
|
}
|
84
pkg/utils/email.go
Normal file
84
pkg/utils/email.go
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"gopkg.in/gomail.v2"
|
||||||
|
"html/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
type emailUtils struct{}
|
||||||
|
|
||||||
|
var Email = emailUtils{}
|
||||||
|
|
||||||
|
type EmailConfig struct {
|
||||||
|
Enable bool // 邮箱启用状态
|
||||||
|
Username string // 邮箱用户名
|
||||||
|
Address string // 邮箱地址
|
||||||
|
Host string // 邮箱服务器地址
|
||||||
|
Port int // 邮箱服务器端口
|
||||||
|
Password string // 邮箱密码
|
||||||
|
SSL bool // 是否使用SSL
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendTemplate 发送HTML模板,从配置文件中读取邮箱配置,支持上下文控制
|
||||||
|
func (e *emailUtils) SendTemplate(emailConfig *EmailConfig, target, subject, htmlTemplate string, data map[string]interface{}) error {
|
||||||
|
// 使用Go的模板系统处理HTML模板
|
||||||
|
tmpl, err := template.New("email").Parse(htmlTemplate)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("解析模板失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, data); err != nil {
|
||||||
|
return fmt.Errorf("执行模板失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送处理后的HTML内容
|
||||||
|
return e.SendEmail(emailConfig, target, subject, buf.String(), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendEmail 使用gomail库发送邮件
|
||||||
|
func (e *emailUtils) SendEmail(emailConfig *EmailConfig, target, subject, content string, isHTML bool) error {
|
||||||
|
if !emailConfig.Enable {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// 创建新邮件
|
||||||
|
m := gomail.NewMessage()
|
||||||
|
m.SetHeader("From", emailConfig.Address)
|
||||||
|
m.SetHeader("To", target)
|
||||||
|
m.SetHeader("Subject", subject)
|
||||||
|
// 设置内容类型
|
||||||
|
if isHTML {
|
||||||
|
m.SetBody("text/html", content)
|
||||||
|
} else {
|
||||||
|
m.SetBody("text/plain", content)
|
||||||
|
}
|
||||||
|
// 创建发送器
|
||||||
|
d := gomail.NewDialer(emailConfig.Host, emailConfig.Port, emailConfig.Username, emailConfig.Password)
|
||||||
|
// 配置SSL/TLS
|
||||||
|
if emailConfig.SSL {
|
||||||
|
d.SSL = true
|
||||||
|
} else {
|
||||||
|
// 对于非SSL但需要STARTTLS的情况
|
||||||
|
d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
|
||||||
|
}
|
||||||
|
// 发送邮件
|
||||||
|
if err := d.DialAndSend(m); err != nil {
|
||||||
|
return fmt.Errorf("发送邮件失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *emailUtils) GetEmailConfigFromEnv() *EmailConfig {
|
||||||
|
return &EmailConfig{
|
||||||
|
Enable: Env.GetenvAsBool("EMAIL_ENABLE", false),
|
||||||
|
Username: Env.Get("EMAIL_USERNAME", ""),
|
||||||
|
Address: Env.Get("EMAIL_ADDRESS", ""),
|
||||||
|
Host: Env.Get("EMAIL_HOST", "smtp.example.com"),
|
||||||
|
Port: Env.GetenvAsInt("EMAIL_PORT", 587),
|
||||||
|
Password: Env.Get("EMAIL_PASSWORD", ""),
|
||||||
|
SSL: Env.GetenvAsBool("EMAIL_SSL", true),
|
||||||
|
}
|
||||||
|
}
|
@ -2,19 +2,27 @@ package utils
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
|
"github.com/snowykami/neo-blog/pkg/constant"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
IsDevMode = false
|
||||||
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
_ = godotenv.Load()
|
_ = godotenv.Load()
|
||||||
|
|
||||||
|
// Init env
|
||||||
|
IsDevMode = Env.Get(constant.EnvKeyMode, constant.ModeDev) == constant.ModeDev
|
||||||
}
|
}
|
||||||
|
|
||||||
type envType struct{}
|
type envUtils struct{}
|
||||||
|
|
||||||
var Env envType
|
var Env envUtils
|
||||||
|
|
||||||
func (e *envType) Get(key string, defaultValue ...string) string {
|
func (e *envUtils) Get(key string, defaultValue ...string) string {
|
||||||
value := os.Getenv(key)
|
value := os.Getenv(key)
|
||||||
if value == "" && len(defaultValue) > 0 {
|
if value == "" && len(defaultValue) > 0 {
|
||||||
return defaultValue[0]
|
return defaultValue[0]
|
||||||
@ -22,7 +30,7 @@ func (e *envType) Get(key string, defaultValue ...string) string {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *envType) GetenvAsInt(key string, defaultValue ...int) int {
|
func (e *envUtils) GetenvAsInt(key string, defaultValue ...int) int {
|
||||||
value := os.Getenv(key)
|
value := os.Getenv(key)
|
||||||
if value == "" && len(defaultValue) > 0 {
|
if value == "" && len(defaultValue) > 0 {
|
||||||
return defaultValue[0]
|
return defaultValue[0]
|
||||||
@ -34,7 +42,7 @@ func (e *envType) GetenvAsInt(key string, defaultValue ...int) int {
|
|||||||
return intValue
|
return intValue
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *envType) GetenvAsBool(key string, defaultValue ...bool) bool {
|
func (e *envUtils) GetenvAsBool(key string, defaultValue ...bool) bool {
|
||||||
value := os.Getenv(key)
|
value := os.Getenv(key)
|
||||||
if value == "" && len(defaultValue) > 0 {
|
if value == "" && len(defaultValue) > 0 {
|
||||||
return defaultValue[0]
|
return defaultValue[0]
|
||||||
|
52
pkg/utils/json_web_token.go
Normal file
52
pkg/utils/json_web_token.go
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/snowykami/neo-blog/pkg/constant"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type jwtUtils struct{}
|
||||||
|
|
||||||
|
var Jwt = jwtUtils{}
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
UserID uint `json:"user_id"`
|
||||||
|
SessionKey string `json:"session_key"` // 会话ID,仅在有状态Token中使用
|
||||||
|
Stateful bool `json:"stateful"` // 是否为有状态Token
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClaims 创建一个新的Claims实例,对于无状态
|
||||||
|
func (j *jwtUtils) NewClaims(userID uint, sessionKey string, stateful bool, duration time.Duration) *Claims {
|
||||||
|
return &Claims{
|
||||||
|
UserID: userID,
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
Stateful: stateful,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(duration)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToString 将Claims转换为JWT字符串
|
||||||
|
func (c *Claims) ToString() (string, error) {
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
|
||||||
|
return token.SignedString([]byte(Env.Get(constant.EnvKeyJwtSecrete, "default_jwt_secret")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseJsonWebTokenWithoutState 解析JWT令牌,不对有状态的Token进行状态检查
|
||||||
|
func (j *jwtUtils) ParseJsonWebTokenWithoutState(tokenString string) (*Claims, error) {
|
||||||
|
claims := &Claims{}
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (any, error) {
|
||||||
|
return []byte(Env.Get(constant.EnvKeyJwtSecrete, "default_jwt_secret")), nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !token.Valid {
|
||||||
|
return nil, jwt.ErrSignatureInvalid
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
119
pkg/utils/kvstore.go
Normal file
119
pkg/utils/kvstore.go
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type kvStoreUtils struct{}
|
||||||
|
|
||||||
|
var KV kvStoreUtils
|
||||||
|
|
||||||
|
// KVStore 是一个简单的内存键值存储系统
|
||||||
|
type KVStore struct {
|
||||||
|
data map[string]storeItem
|
||||||
|
mutex sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeItem 代表存储的单个数据项
|
||||||
|
type storeItem struct {
|
||||||
|
value interface{}
|
||||||
|
expiration int64 // Unix时间戳,0表示永不过期
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局单例
|
||||||
|
var (
|
||||||
|
kvStore *KVStore
|
||||||
|
kvStoreOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetInstance 获取KVStore单例实例
|
||||||
|
func (kv *kvStoreUtils) GetInstance() *KVStore {
|
||||||
|
kvStoreOnce.Do(func() {
|
||||||
|
kvStore = &KVStore{
|
||||||
|
data: make(map[string]storeItem),
|
||||||
|
}
|
||||||
|
// 启动清理过期项的协程
|
||||||
|
go kvStore.startCleanupTimer()
|
||||||
|
})
|
||||||
|
return kvStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set 设置键值对,可选指定过期时间
|
||||||
|
func (s *KVStore) Set(key string, value interface{}, ttl time.Duration) {
|
||||||
|
s.mutex.Lock()
|
||||||
|
defer s.mutex.Unlock()
|
||||||
|
|
||||||
|
var expiration int64
|
||||||
|
if ttl > 0 {
|
||||||
|
expiration = time.Now().Add(ttl).Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
s.data[key] = storeItem{
|
||||||
|
value: value,
|
||||||
|
expiration: expiration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 获取键对应的值,如果键不存在或已过期则返回(nil, false)
|
||||||
|
func (s *KVStore) Get(key string) (interface{}, bool) {
|
||||||
|
s.mutex.RLock()
|
||||||
|
defer s.mutex.RUnlock()
|
||||||
|
|
||||||
|
item, exists := s.data[key]
|
||||||
|
if !exists {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否过期
|
||||||
|
if item.expiration > 0 && time.Now().Unix() > item.expiration {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除键值对
|
||||||
|
func (s *KVStore) Delete(key string) {
|
||||||
|
s.mutex.Lock()
|
||||||
|
defer s.mutex.Unlock()
|
||||||
|
|
||||||
|
delete(s.data, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear 清空所有键值对
|
||||||
|
func (s *KVStore) Clear() {
|
||||||
|
s.mutex.Lock()
|
||||||
|
defer s.mutex.Unlock()
|
||||||
|
|
||||||
|
s.data = make(map[string]storeItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startCleanupTimer 启动定期清理过期项的计时器
|
||||||
|
func (s *KVStore) startCleanupTimer() {
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
s.cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanup 清理过期的数据项
|
||||||
|
func (s *KVStore) cleanup() {
|
||||||
|
s.mutex.Lock()
|
||||||
|
defer s.mutex.Unlock()
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
for key, item := range s.data {
|
||||||
|
if item.expiration > 0 && now > item.expiration {
|
||||||
|
delete(s.data, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// secureRand 生成0到max-1之间的安全随机数
|
||||||
|
func secureRand(max int) int {
|
||||||
|
// 简单实现,可以根据需要使用crypto/rand替换
|
||||||
|
return int(time.Now().UnixNano() % int64(max))
|
||||||
|
}
|
@ -6,13 +6,13 @@ import (
|
|||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PasswordType struct {
|
type PasswordUtils struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
var Password = PasswordType{}
|
var Password = PasswordUtils{}
|
||||||
|
|
||||||
// HashPassword 密码哈希函数
|
// HashPassword 密码哈希函数
|
||||||
func (u *PasswordType) HashPassword(password string, salt string) (string, error) {
|
func (u *PasswordUtils) HashPassword(password string, salt string) (string, error) {
|
||||||
saltedPassword := Password.addSalt(password, salt)
|
saltedPassword := Password.addSalt(password, salt)
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(saltedPassword), bcrypt.DefaultCost)
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(saltedPassword), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -22,7 +22,7 @@ func (u *PasswordType) HashPassword(password string, salt string) (string, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
// VerifyPassword 验证密码
|
// VerifyPassword 验证密码
|
||||||
func (u *PasswordType) VerifyPassword(password, hashedPassword string, salt string) bool {
|
func (u *PasswordUtils) VerifyPassword(password, hashedPassword string, salt string) bool {
|
||||||
if len(hashedPassword) == 0 || len(salt) == 0 {
|
if len(hashedPassword) == 0 || len(salt) == 0 {
|
||||||
// 防止oidc空密码出问题
|
// 防止oidc空密码出问题
|
||||||
return false
|
return false
|
||||||
@ -33,7 +33,7 @@ func (u *PasswordType) VerifyPassword(password, hashedPassword string, salt stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
// addSalt 加盐函数
|
// addSalt 加盐函数
|
||||||
func (u *PasswordType) addSalt(password string, salt string) string {
|
func (u *PasswordUtils) addSalt(password string, salt string) string {
|
||||||
combined := password + salt
|
combined := password + salt
|
||||||
hash := sha256.New()
|
hash := sha256.New()
|
||||||
hash.Write([]byte(combined))
|
hash.Write([]byte(combined))
|
||||||
|
1
pkg/utils/request_context.go
Normal file
1
pkg/utils/request_context.go
Normal file
@ -0,0 +1 @@
|
|||||||
|
package utils
|
27
pkg/utils/strings.go
Normal file
27
pkg/utils/strings.go
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import "math/rand"
|
||||||
|
|
||||||
|
type stringsUtils struct{}
|
||||||
|
|
||||||
|
var Strings = stringsUtils{}
|
||||||
|
|
||||||
|
func (s *stringsUtils) GenerateRandomString(length int) string {
|
||||||
|
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
result := make([]byte, length)
|
||||||
|
for i := range result {
|
||||||
|
result[i] = charset[rand.Intn(len(charset))]
|
||||||
|
}
|
||||||
|
return string(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stringsUtils) GenerateRandomStringWithCharset(length int, charset string) string {
|
||||||
|
if charset == "" {
|
||||||
|
charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
}
|
||||||
|
result := make([]byte, length)
|
||||||
|
for i := range result {
|
||||||
|
result[i] = charset[rand.Intn(len(charset))]
|
||||||
|
}
|
||||||
|
return string(result)
|
||||||
|
}
|
Reference in New Issue
Block a user