chore: sync
This commit is contained in:
49
sproutgate-backend/internal/auth/jwt.go
Normal file
49
sproutgate-backend/internal/auth/jwt.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
Account string `json:"account"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func GenerateToken(secret []byte, issuer string, account string, ttl time.Duration) (string, time.Time, error) {
|
||||
if account == "" {
|
||||
return "", time.Time{}, errors.New("account is required")
|
||||
}
|
||||
expiresAt := time.Now().Add(ttl)
|
||||
claims := Claims{
|
||||
Account: account,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: issuer,
|
||||
Subject: account,
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
signed, err := token.SignedString(secret)
|
||||
return signed, expiresAt, err
|
||||
}
|
||||
|
||||
func ParseToken(secret []byte, issuer string, tokenString string) (*Claims, error) {
|
||||
if tokenString == "" {
|
||||
return nil, errors.New("token is required")
|
||||
}
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
|
||||
return secret, nil
|
||||
}, jwt.WithIssuer(issuer))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
167
sproutgate-backend/internal/email/mailer.go
Normal file
167
sproutgate-backend/internal/email/mailer.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sproutgate-backend/internal/storage"
|
||||
)
|
||||
|
||||
func SendVerificationEmail(cfg storage.EmailConfig, to string, code string, expiresIn time.Duration) error {
|
||||
if strings.TrimSpace(to) == "" {
|
||||
return fmt.Errorf("email is required")
|
||||
}
|
||||
fromName := strings.TrimSpace(cfg.FromName)
|
||||
if fromName == "" {
|
||||
fromName = "萌芽账户认证中心"
|
||||
}
|
||||
fromAddress := strings.TrimSpace(cfg.FromAddress)
|
||||
if fromAddress == "" {
|
||||
return fmt.Errorf("from address is required")
|
||||
}
|
||||
username := strings.TrimSpace(cfg.Username)
|
||||
if username == "" {
|
||||
username = fromAddress
|
||||
}
|
||||
subject := "萌芽账户认证中心 - 邮箱验证"
|
||||
encodedName := mime.QEncoding.Encode("UTF-8", fromName)
|
||||
fromHeader := fmt.Sprintf("%s <%s>", encodedName, fromAddress)
|
||||
|
||||
body := fmt.Sprintf("您的验证码是:%s\n有效期:%d 分钟\n\n如非本人操作,请忽略此邮件。",
|
||||
code, int(expiresIn.Minutes()))
|
||||
|
||||
var msg bytes.Buffer
|
||||
msg.WriteString("From: " + fromHeader + "\r\n")
|
||||
msg.WriteString("To: " + to + "\r\n")
|
||||
msg.WriteString("Subject: " + mime.QEncoding.Encode("UTF-8", subject) + "\r\n")
|
||||
msg.WriteString("MIME-Version: 1.0\r\n")
|
||||
msg.WriteString("Content-Type: text/plain; charset=\"UTF-8\"\r\n")
|
||||
msg.WriteString("Content-Transfer-Encoding: 8bit\r\n")
|
||||
msg.WriteString("\r\n")
|
||||
msg.WriteString(body)
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", cfg.SMTPHost, cfg.SMTPPort)
|
||||
auth := smtp.PlainAuth("", username, cfg.Password, cfg.SMTPHost)
|
||||
encryption := strings.ToUpper(strings.TrimSpace(cfg.Encryption))
|
||||
|
||||
if cfg.SMTPPort == 465 || encryption == "SSL" {
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: cfg.SMTPHost,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
InsecureSkipVerify: false,
|
||||
}
|
||||
conn, err := tls.Dial("tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := smtp.NewClient(conn, cfg.SMTPHost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Mail(fromAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Rcpt(to); err != nil {
|
||||
return err
|
||||
}
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := writer.Write(msg.Bytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
return smtp.SendMail(addr, auth, fromAddress, []string{to}, msg.Bytes())
|
||||
}
|
||||
|
||||
func SendResetPasswordEmail(cfg storage.EmailConfig, to string, code string, expiresIn time.Duration) error {
|
||||
if strings.TrimSpace(to) == "" {
|
||||
return fmt.Errorf("email is required")
|
||||
}
|
||||
fromName := strings.TrimSpace(cfg.FromName)
|
||||
if fromName == "" {
|
||||
fromName = "萌芽账户认证中心"
|
||||
}
|
||||
fromAddress := strings.TrimSpace(cfg.FromAddress)
|
||||
if fromAddress == "" {
|
||||
return fmt.Errorf("from address is required")
|
||||
}
|
||||
username := strings.TrimSpace(cfg.Username)
|
||||
if username == "" {
|
||||
username = fromAddress
|
||||
}
|
||||
subject := "萌芽账户认证中心 - 重置密码"
|
||||
encodedName := mime.QEncoding.Encode("UTF-8", fromName)
|
||||
fromHeader := fmt.Sprintf("%s <%s>", encodedName, fromAddress)
|
||||
|
||||
body := fmt.Sprintf("您的重置密码验证码是:%s\n有效期:%d 分钟\n\n如非本人操作,请忽略此邮件。",
|
||||
code, int(expiresIn.Minutes()))
|
||||
|
||||
var msg bytes.Buffer
|
||||
msg.WriteString("From: " + fromHeader + "\r\n")
|
||||
msg.WriteString("To: " + to + "\r\n")
|
||||
msg.WriteString("Subject: " + mime.QEncoding.Encode("UTF-8", subject) + "\r\n")
|
||||
msg.WriteString("MIME-Version: 1.0\r\n")
|
||||
msg.WriteString("Content-Type: text/plain; charset=\"UTF-8\"\r\n")
|
||||
msg.WriteString("Content-Transfer-Encoding: 8bit\r\n")
|
||||
msg.WriteString("\r\n")
|
||||
msg.WriteString(body)
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", cfg.SMTPHost, cfg.SMTPPort)
|
||||
auth := smtp.PlainAuth("", username, cfg.Password, cfg.SMTPHost)
|
||||
encryption := strings.ToUpper(strings.TrimSpace(cfg.Encryption))
|
||||
|
||||
if cfg.SMTPPort == 465 || encryption == "SSL" {
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: cfg.SMTPHost,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
InsecureSkipVerify: false,
|
||||
}
|
||||
conn, err := tls.Dial("tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := smtp.NewClient(conn, cfg.SMTPHost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Mail(fromAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Rcpt(to); err != nil {
|
||||
return err
|
||||
}
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := writer.Write(msg.Bytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
return smtp.SendMail(addr, auth, fromAddress, []string{to}, msg.Bytes())
|
||||
}
|
||||
751
sproutgate-backend/internal/handlers/handlers.go
Normal file
751
sproutgate-backend/internal/handlers/handlers.go
Normal file
@@ -0,0 +1,751 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"sproutgate-backend/internal/auth"
|
||||
"sproutgate-backend/internal/email"
|
||||
"sproutgate-backend/internal/models"
|
||||
"sproutgate-backend/internal/storage"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
store *storage.Store
|
||||
}
|
||||
|
||||
func NewHandler(store *storage.Store) *Handler {
|
||||
return &Handler{store: store}
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Account string `json:"account"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type verifyRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type registerRequest struct {
|
||||
Account string `json:"account"`
|
||||
Password string `json:"password"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type verifyEmailRequest struct {
|
||||
Account string `json:"account"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type updateProfileRequest struct {
|
||||
Password *string `json:"password"`
|
||||
Username *string `json:"username"`
|
||||
Phone *string `json:"phone"`
|
||||
AvatarURL *string `json:"avatarUrl"`
|
||||
Bio *string `json:"bio"`
|
||||
}
|
||||
|
||||
type forgotPasswordRequest struct {
|
||||
Account string `json:"account"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type resetPasswordRequest struct {
|
||||
Account string `json:"account"`
|
||||
Code string `json:"code"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
type secondaryEmailRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type verifySecondaryEmailRequest struct {
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type createUserRequest struct {
|
||||
Account string `json:"account"`
|
||||
Password string `json:"password"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Level int `json:"level"`
|
||||
SproutCoins int `json:"sproutCoins"`
|
||||
SecondaryEmails []string `json:"secondaryEmails"`
|
||||
Phone string `json:"phone"`
|
||||
AvatarURL string `json:"avatarUrl"`
|
||||
Bio string `json:"bio"`
|
||||
}
|
||||
|
||||
type updateUserRequest struct {
|
||||
Password *string `json:"password"`
|
||||
Username *string `json:"username"`
|
||||
Email *string `json:"email"`
|
||||
Level *int `json:"level"`
|
||||
SproutCoins *int `json:"sproutCoins"`
|
||||
SecondaryEmails *[]string `json:"secondaryEmails"`
|
||||
Phone *string `json:"phone"`
|
||||
AvatarURL *string `json:"avatarUrl"`
|
||||
Bio *string `json:"bio"`
|
||||
}
|
||||
|
||||
func (h *Handler) Login(c *gin.Context) {
|
||||
var req loginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
req.Account = strings.TrimSpace(req.Account)
|
||||
if req.Account == "" || req.Password == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account and password are required"})
|
||||
return
|
||||
}
|
||||
user, found, err := h.store.GetUser(req.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
token, expiresAt, err := auth.GenerateToken(h.store.JWTSecret(), h.store.JWTIssuer(), user.Account, 7*24*time.Hour)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"expiresAt": expiresAt.Format(time.RFC3339),
|
||||
"user": user.Public(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) Verify(c *gin.Context) {
|
||||
var req verifyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Token) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"})
|
||||
return
|
||||
}
|
||||
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), req.Token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"valid": false, "error": "invalid token"})
|
||||
return
|
||||
}
|
||||
user, found, err := h.store.GetUser(claims.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"valid": false, "error": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"valid": true, "user": user.Public()})
|
||||
}
|
||||
|
||||
func (h *Handler) Register(c *gin.Context) {
|
||||
var req registerRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
req.Account = strings.TrimSpace(req.Account)
|
||||
req.Email = strings.TrimSpace(req.Email)
|
||||
if req.Account == "" || strings.TrimSpace(req.Password) == "" || req.Email == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account, password and email are required"})
|
||||
return
|
||||
}
|
||||
if _, found, err := h.store.GetUser(req.Account); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
} else if found {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account already exists"})
|
||||
return
|
||||
}
|
||||
|
||||
code, err := generateVerificationCode()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"})
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
||||
return
|
||||
}
|
||||
expiresAt := time.Now().Add(10 * time.Minute)
|
||||
pending := models.PendingUser{
|
||||
Account: req.Account,
|
||||
PasswordHash: string(hash),
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
CodeHash: hashCode(code),
|
||||
CreatedAt: models.NowISO(),
|
||||
ExpiresAt: expiresAt.Format(time.RFC3339),
|
||||
}
|
||||
if err := h.store.SavePending(pending); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save pending user"})
|
||||
return
|
||||
}
|
||||
if err := email.SendVerificationEmail(h.store.EmailConfig(), req.Email, code, 10*time.Minute); err != nil {
|
||||
_ = h.store.DeletePending(req.Account)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"sent": true,
|
||||
"expiresAt": expiresAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) VerifyEmail(c *gin.Context) {
|
||||
var req verifyEmailRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
req.Account = strings.TrimSpace(req.Account)
|
||||
req.Code = strings.TrimSpace(req.Code)
|
||||
if req.Account == "" || req.Code == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account and code are required"})
|
||||
return
|
||||
}
|
||||
pending, found, err := h.store.GetPending(req.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load pending user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "pending registration not found"})
|
||||
return
|
||||
}
|
||||
expiresAt, err := time.Parse(time.RFC3339, pending.ExpiresAt)
|
||||
if err != nil || time.Now().After(expiresAt) {
|
||||
_ = h.store.DeletePending(req.Account)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "verification code expired"})
|
||||
return
|
||||
}
|
||||
if !verifyCode(req.Code, pending.CodeHash) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid verification code"})
|
||||
return
|
||||
}
|
||||
record := models.UserRecord{
|
||||
Account: pending.Account,
|
||||
PasswordHash: pending.PasswordHash,
|
||||
Username: pending.Username,
|
||||
Email: pending.Email,
|
||||
Level: 0,
|
||||
SproutCoins: 0,
|
||||
SecondaryEmails: []string{},
|
||||
CreatedAt: models.NowISO(),
|
||||
UpdatedAt: models.NowISO(),
|
||||
}
|
||||
if err := h.store.CreateUser(record); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = h.store.DeletePending(req.Account)
|
||||
c.JSON(http.StatusCreated, gin.H{"created": true, "user": record.Public()})
|
||||
}
|
||||
|
||||
func (h *Handler) ForgotPassword(c *gin.Context) {
|
||||
var req forgotPasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
req.Account = strings.TrimSpace(req.Account)
|
||||
req.Email = strings.TrimSpace(req.Email)
|
||||
if req.Account == "" || req.Email == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account and email are required"})
|
||||
return
|
||||
}
|
||||
user, found, err := h.store.GetUser(req.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found || strings.TrimSpace(user.Email) == "" || user.Email != req.Email {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account or email not matched"})
|
||||
return
|
||||
}
|
||||
code, err := generateVerificationCode()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"})
|
||||
return
|
||||
}
|
||||
expiresAt := time.Now().Add(10 * time.Minute)
|
||||
resetRecord := models.ResetPassword{
|
||||
Account: user.Account,
|
||||
Email: user.Email,
|
||||
CodeHash: hashCode(code),
|
||||
CreatedAt: models.NowISO(),
|
||||
ExpiresAt: expiresAt.Format(time.RFC3339),
|
||||
}
|
||||
if err := h.store.SaveReset(resetRecord); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save reset token"})
|
||||
return
|
||||
}
|
||||
if err := email.SendResetPasswordEmail(h.store.EmailConfig(), user.Email, code, 10*time.Minute); err != nil {
|
||||
_ = h.store.DeleteReset(user.Account)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"sent": true,
|
||||
"expiresAt": expiresAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) ResetPassword(c *gin.Context) {
|
||||
var req resetPasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
req.Account = strings.TrimSpace(req.Account)
|
||||
req.Code = strings.TrimSpace(req.Code)
|
||||
if req.Account == "" || req.Code == "" || strings.TrimSpace(req.NewPassword) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account, code and newPassword are required"})
|
||||
return
|
||||
}
|
||||
resetRecord, found, err := h.store.GetReset(req.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load reset token"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "reset request not found"})
|
||||
return
|
||||
}
|
||||
expiresAt, err := time.Parse(time.RFC3339, resetRecord.ExpiresAt)
|
||||
if err != nil || time.Now().After(expiresAt) {
|
||||
_ = h.store.DeleteReset(req.Account)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "reset code expired"})
|
||||
return
|
||||
}
|
||||
if !verifyCode(req.Code, resetRecord.CodeHash) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid reset code"})
|
||||
return
|
||||
}
|
||||
user, found, err := h.store.GetUser(req.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
||||
return
|
||||
}
|
||||
user.PasswordHash = string(hash)
|
||||
if err := h.store.SaveUser(user); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"})
|
||||
return
|
||||
}
|
||||
_ = h.store.DeleteReset(req.Account)
|
||||
c.JSON(http.StatusOK, gin.H{"reset": true})
|
||||
}
|
||||
|
||||
func (h *Handler) RequestSecondaryEmail(c *gin.Context) {
|
||||
token := bearerToken(c.GetHeader("Authorization"))
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
||||
return
|
||||
}
|
||||
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
var req secondaryEmailRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
emailAddr := strings.TrimSpace(req.Email)
|
||||
if emailAddr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email is required"})
|
||||
return
|
||||
}
|
||||
user, found, err := h.store.GetUser(claims.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(user.Email) == emailAddr {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email already used as primary"})
|
||||
return
|
||||
}
|
||||
for _, e := range user.SecondaryEmails {
|
||||
if e == emailAddr {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email already verified"})
|
||||
return
|
||||
}
|
||||
}
|
||||
code, err := generateVerificationCode()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"})
|
||||
return
|
||||
}
|
||||
expiresAt := time.Now().Add(10 * time.Minute)
|
||||
record := models.SecondaryEmailVerification{
|
||||
Account: user.Account,
|
||||
Email: emailAddr,
|
||||
CodeHash: hashCode(code),
|
||||
CreatedAt: models.NowISO(),
|
||||
ExpiresAt: expiresAt.Format(time.RFC3339),
|
||||
}
|
||||
if err := h.store.SaveSecondaryVerification(record); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save verification"})
|
||||
return
|
||||
}
|
||||
if err := email.SendVerificationEmail(h.store.EmailConfig(), emailAddr, code, 10*time.Minute); err != nil {
|
||||
_ = h.store.DeleteSecondaryVerification(user.Account, emailAddr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"sent": true,
|
||||
"expiresAt": expiresAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) VerifySecondaryEmail(c *gin.Context) {
|
||||
token := bearerToken(c.GetHeader("Authorization"))
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
||||
return
|
||||
}
|
||||
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
var req verifySecondaryEmailRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
emailAddr := strings.TrimSpace(req.Email)
|
||||
code := strings.TrimSpace(req.Code)
|
||||
if emailAddr == "" || code == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email and code are required"})
|
||||
return
|
||||
}
|
||||
record, found, err := h.store.GetSecondaryVerification(claims.Account, emailAddr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load verification"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "verification not found"})
|
||||
return
|
||||
}
|
||||
expiresAt, err := time.Parse(time.RFC3339, record.ExpiresAt)
|
||||
if err != nil || time.Now().After(expiresAt) {
|
||||
_ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "verification code expired"})
|
||||
return
|
||||
}
|
||||
if !verifyCode(code, record.CodeHash) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid verification code"})
|
||||
return
|
||||
}
|
||||
user, found, err := h.store.GetUser(claims.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
for _, e := range user.SecondaryEmails {
|
||||
if e == emailAddr {
|
||||
_ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr)
|
||||
c.JSON(http.StatusOK, gin.H{"verified": true, "user": user.Public()})
|
||||
return
|
||||
}
|
||||
}
|
||||
user.SecondaryEmails = append(user.SecondaryEmails, emailAddr)
|
||||
if err := h.store.SaveUser(user); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"})
|
||||
return
|
||||
}
|
||||
_ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr)
|
||||
c.JSON(http.StatusOK, gin.H{"verified": true, "user": user.Public()})
|
||||
}
|
||||
|
||||
func (h *Handler) Me(c *gin.Context) {
|
||||
token := bearerToken(c.GetHeader("Authorization"))
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
||||
return
|
||||
}
|
||||
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
user, found, err := h.store.GetUser(claims.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"user": user.Public()})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateProfile(c *gin.Context) {
|
||||
token := bearerToken(c.GetHeader("Authorization"))
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
||||
return
|
||||
}
|
||||
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
var req updateProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
user, found, err := h.store.GetUser(claims.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if req.Password != nil && strings.TrimSpace(*req.Password) != "" {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
||||
return
|
||||
}
|
||||
user.PasswordHash = string(hash)
|
||||
}
|
||||
if req.Username != nil {
|
||||
user.Username = *req.Username
|
||||
}
|
||||
if req.Phone != nil {
|
||||
user.Phone = *req.Phone
|
||||
}
|
||||
if req.AvatarURL != nil {
|
||||
user.AvatarURL = *req.AvatarURL
|
||||
}
|
||||
if req.Bio != nil {
|
||||
user.Bio = *req.Bio
|
||||
}
|
||||
if err := h.store.SaveUser(user); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"user": user.Public()})
|
||||
}
|
||||
|
||||
func (h *Handler) ListUsers(c *gin.Context) {
|
||||
users, err := h.store.ListUsers()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load users"})
|
||||
return
|
||||
}
|
||||
publicUsers := make([]models.UserPublic, 0, len(users))
|
||||
for _, u := range users {
|
||||
publicUsers = append(publicUsers, u.Public())
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"total": len(publicUsers), "users": publicUsers})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateUser(c *gin.Context) {
|
||||
var req createUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
req.Account = strings.TrimSpace(req.Account)
|
||||
if req.Account == "" || strings.TrimSpace(req.Password) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account and password are required"})
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
||||
return
|
||||
}
|
||||
record := models.UserRecord{
|
||||
Account: req.Account,
|
||||
PasswordHash: string(hash),
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Level: req.Level,
|
||||
SproutCoins: req.SproutCoins,
|
||||
SecondaryEmails: req.SecondaryEmails,
|
||||
Phone: req.Phone,
|
||||
AvatarURL: req.AvatarURL,
|
||||
Bio: req.Bio,
|
||||
CreatedAt: models.NowISO(),
|
||||
UpdatedAt: models.NowISO(),
|
||||
}
|
||||
if err := h.store.CreateUser(record); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"user": record.Public()})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateUser(c *gin.Context) {
|
||||
account := strings.TrimSpace(c.Param("account"))
|
||||
if account == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"})
|
||||
return
|
||||
}
|
||||
var req updateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
user, found, err := h.store.GetUser(account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if req.Password != nil && strings.TrimSpace(*req.Password) != "" {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
||||
return
|
||||
}
|
||||
user.PasswordHash = string(hash)
|
||||
}
|
||||
if req.Username != nil {
|
||||
user.Username = *req.Username
|
||||
}
|
||||
if req.Email != nil {
|
||||
user.Email = *req.Email
|
||||
}
|
||||
if req.Level != nil {
|
||||
user.Level = *req.Level
|
||||
}
|
||||
if req.SproutCoins != nil {
|
||||
user.SproutCoins = *req.SproutCoins
|
||||
}
|
||||
if req.SecondaryEmails != nil {
|
||||
user.SecondaryEmails = *req.SecondaryEmails
|
||||
}
|
||||
if req.Phone != nil {
|
||||
user.Phone = *req.Phone
|
||||
}
|
||||
if req.AvatarURL != nil {
|
||||
user.AvatarURL = *req.AvatarURL
|
||||
}
|
||||
if req.Bio != nil {
|
||||
user.Bio = *req.Bio
|
||||
}
|
||||
if err := h.store.SaveUser(user); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"user": user.Public()})
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteUser(c *gin.Context) {
|
||||
account := strings.TrimSpace(c.Param("account"))
|
||||
if account == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"})
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteUser(account); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
func (h *Handler) AdminMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := adminTokenFromRequest(c)
|
||||
if token == "" || token != h.store.AdminToken() {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid admin token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func adminTokenFromRequest(c *gin.Context) string {
|
||||
if token := strings.TrimSpace(c.Query("token")); token != "" {
|
||||
return token
|
||||
}
|
||||
if token := strings.TrimSpace(c.GetHeader("X-Admin-Token")); token != "" {
|
||||
return token
|
||||
}
|
||||
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
return bearerToken(authHeader)
|
||||
}
|
||||
|
||||
func bearerToken(header string) string {
|
||||
if header == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(header), "bearer ") {
|
||||
return strings.TrimSpace(header[7:])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func generateVerificationCode() (string, error) {
|
||||
randomBytes := make([]byte, 3)
|
||||
if _, err := rand.Read(randomBytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
number := int(randomBytes[0])<<16 | int(randomBytes[1])<<8 | int(randomBytes[2])
|
||||
return fmt.Sprintf("%06d", number%1000000), nil
|
||||
}
|
||||
|
||||
func hashCode(code string) string {
|
||||
sum := sha256.Sum256([]byte(code))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func verifyCode(code string, hash string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(hashCode(code)), []byte(hash)) == 1
|
||||
}
|
||||
11
sproutgate-backend/internal/models/pending.go
Normal file
11
sproutgate-backend/internal/models/pending.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package models
|
||||
|
||||
type PendingUser struct {
|
||||
Account string `json:"account"`
|
||||
PasswordHash string `json:"passwordHash"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
CodeHash string `json:"codeHash"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
9
sproutgate-backend/internal/models/reset.go
Normal file
9
sproutgate-backend/internal/models/reset.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package models
|
||||
|
||||
type ResetPassword struct {
|
||||
Account string `json:"account"`
|
||||
Email string `json:"email"`
|
||||
CodeHash string `json:"codeHash"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
9
sproutgate-backend/internal/models/secondary_email.go
Normal file
9
sproutgate-backend/internal/models/secondary_email.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package models
|
||||
|
||||
type SecondaryEmailVerification struct {
|
||||
Account string `json:"account"`
|
||||
Email string `json:"email"`
|
||||
CodeHash string `json:"codeHash"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
52
sproutgate-backend/internal/models/user.go
Normal file
52
sproutgate-backend/internal/models/user.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type UserRecord struct {
|
||||
Account string `json:"account"`
|
||||
PasswordHash string `json:"passwordHash"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Level int `json:"level"`
|
||||
SproutCoins int `json:"sproutCoins"`
|
||||
SecondaryEmails []string `json:"secondaryEmails,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
Bio string `json:"bio,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type UserPublic struct {
|
||||
Account string `json:"account"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Level int `json:"level"`
|
||||
SproutCoins int `json:"sproutCoins"`
|
||||
SecondaryEmails []string `json:"secondaryEmails,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
Bio string `json:"bio,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (u UserRecord) Public() UserPublic {
|
||||
return UserPublic{
|
||||
Account: u.Account,
|
||||
Username: u.Username,
|
||||
Email: u.Email,
|
||||
Level: u.Level,
|
||||
SproutCoins: u.SproutCoins,
|
||||
SecondaryEmails: u.SecondaryEmails,
|
||||
Phone: u.Phone,
|
||||
AvatarURL: u.AvatarURL,
|
||||
Bio: u.Bio,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func NowISO() string {
|
||||
return time.Now().Format(time.RFC3339)
|
||||
}
|
||||
55
sproutgate-backend/internal/storage/pending.go
Normal file
55
sproutgate-backend/internal/storage/pending.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
func (s *Store) SavePending(record models.PendingUser) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.pendingFilePath(record.Account)
|
||||
return writeJSONFile(path, record)
|
||||
}
|
||||
|
||||
func (s *Store) GetPending(account string) (models.PendingUser, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.pendingFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return models.PendingUser{}, false, nil
|
||||
}
|
||||
var record models.PendingUser
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.PendingUser{}, false, err
|
||||
}
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeletePending(account string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.pendingFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
func (s *Store) pendingFilePath(account string) string {
|
||||
return filepath.Join(s.pendingDir, pendingFileName(account))
|
||||
}
|
||||
|
||||
func pendingFileName(account string) string {
|
||||
safe := strings.TrimSpace(account)
|
||||
if safe == "" {
|
||||
safe = "unknown"
|
||||
}
|
||||
encoded := base64.RawURLEncoding.EncodeToString([]byte(safe))
|
||||
return encoded + ".json"
|
||||
}
|
||||
55
sproutgate-backend/internal/storage/reset.go
Normal file
55
sproutgate-backend/internal/storage/reset.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
func (s *Store) SaveReset(record models.ResetPassword) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.resetFilePath(record.Account)
|
||||
return writeJSONFile(path, record)
|
||||
}
|
||||
|
||||
func (s *Store) GetReset(account string) (models.ResetPassword, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.resetFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return models.ResetPassword{}, false, nil
|
||||
}
|
||||
var record models.ResetPassword
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.ResetPassword{}, false, err
|
||||
}
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteReset(account string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.resetFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
func (s *Store) resetFilePath(account string) string {
|
||||
return filepath.Join(s.resetDir, resetFileName(account))
|
||||
}
|
||||
|
||||
func resetFileName(account string) string {
|
||||
safe := strings.TrimSpace(account)
|
||||
if safe == "" {
|
||||
safe = "unknown"
|
||||
}
|
||||
encoded := base64.RawURLEncoding.EncodeToString([]byte(safe))
|
||||
return encoded + ".json"
|
||||
}
|
||||
60
sproutgate-backend/internal/storage/secondary.go
Normal file
60
sproutgate-backend/internal/storage/secondary.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
func (s *Store) SaveSecondaryVerification(record models.SecondaryEmailVerification) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.secondaryFilePath(record.Account, record.Email)
|
||||
return writeJSONFile(path, record)
|
||||
}
|
||||
|
||||
func (s *Store) GetSecondaryVerification(account string, email string) (models.SecondaryEmailVerification, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.secondaryFilePath(account, email)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return models.SecondaryEmailVerification{}, false, nil
|
||||
}
|
||||
var record models.SecondaryEmailVerification
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.SecondaryEmailVerification{}, false, err
|
||||
}
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSecondaryVerification(account string, email string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.secondaryFilePath(account, email)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
func (s *Store) secondaryFilePath(account string, email string) string {
|
||||
return filepath.Join(s.secondaryDir, secondaryFileName(account, email))
|
||||
}
|
||||
|
||||
func secondaryFileName(account string, email string) string {
|
||||
accountSafe := strings.TrimSpace(account)
|
||||
emailSafe := strings.TrimSpace(email)
|
||||
if accountSafe == "" {
|
||||
accountSafe = "unknown"
|
||||
}
|
||||
if emailSafe == "" {
|
||||
emailSafe = "unknown"
|
||||
}
|
||||
raw := accountSafe + "::" + emailSafe
|
||||
encoded := base64.RawURLEncoding.EncodeToString([]byte(raw))
|
||||
return encoded + ".json"
|
||||
}
|
||||
340
sproutgate-backend/internal/storage/storage.go
Normal file
340
sproutgate-backend/internal/storage/storage.go
Normal file
@@ -0,0 +1,340 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
type AdminConfig struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
JWTSecret string `json:"jwtSecret"`
|
||||
Issuer string `json:"issuer"`
|
||||
}
|
||||
|
||||
type EmailConfig struct {
|
||||
FromName string `json:"fromName"`
|
||||
FromAddress string `json:"fromAddress"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
SMTPHost string `json:"smtpHost"`
|
||||
SMTPPort int `json:"smtpPort"`
|
||||
Encryption string `json:"encryption"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
dataDir string
|
||||
usersDir string
|
||||
pendingDir string
|
||||
resetDir string
|
||||
secondaryDir string
|
||||
adminConfigPath string
|
||||
authConfigPath string
|
||||
emailConfigPath string
|
||||
adminToken string
|
||||
jwtSecret []byte
|
||||
issuer string
|
||||
emailConfig EmailConfig
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewStore(dataDir string) (*Store, error) {
|
||||
if dataDir == "" {
|
||||
dataDir = "./data"
|
||||
}
|
||||
absDir, err := filepath.Abs(dataDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
usersDir := filepath.Join(absDir, "users")
|
||||
pendingDir := filepath.Join(absDir, "pending")
|
||||
resetDir := filepath.Join(absDir, "reset")
|
||||
secondaryDir := filepath.Join(absDir, "secondary")
|
||||
configDir := filepath.Join(absDir, "config")
|
||||
if err := os.MkdirAll(usersDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(pendingDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(resetDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(secondaryDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store := &Store{
|
||||
dataDir: absDir,
|
||||
usersDir: usersDir,
|
||||
pendingDir: pendingDir,
|
||||
resetDir: resetDir,
|
||||
secondaryDir: secondaryDir,
|
||||
adminConfigPath: filepath.Join(configDir, "admin.json"),
|
||||
authConfigPath: filepath.Join(configDir, "auth.json"),
|
||||
emailConfigPath: filepath.Join(configDir, "email.json"),
|
||||
}
|
||||
if err := store.loadOrCreateAdminConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := store.loadOrCreateAuthConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := store.loadOrCreateEmailConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *Store) DataDir() string {
|
||||
return s.dataDir
|
||||
}
|
||||
|
||||
func (s *Store) AdminToken() string {
|
||||
return s.adminToken
|
||||
}
|
||||
|
||||
func (s *Store) JWTSecret() []byte {
|
||||
return s.jwtSecret
|
||||
}
|
||||
|
||||
func (s *Store) JWTIssuer() string {
|
||||
return s.issuer
|
||||
}
|
||||
|
||||
func (s *Store) EmailConfig() EmailConfig {
|
||||
return s.emailConfig
|
||||
}
|
||||
|
||||
func (s *Store) loadOrCreateAdminConfig() error {
|
||||
defaultToken := "shumengya520"
|
||||
if _, err := os.Stat(s.adminConfigPath); errors.Is(err, os.ErrNotExist) {
|
||||
cfg := AdminConfig{Token: defaultToken}
|
||||
if err := writeJSONFile(s.adminConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.adminToken = cfg.Token
|
||||
return nil
|
||||
}
|
||||
var cfg AdminConfig
|
||||
if err := readJSONFile(s.adminConfigPath, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(cfg.Token) == "" {
|
||||
cfg.Token = defaultToken
|
||||
if err := writeJSONFile(s.adminConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.adminToken = cfg.Token
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) loadOrCreateAuthConfig() error {
|
||||
if _, err := os.Stat(s.authConfigPath); errors.Is(err, os.ErrNotExist) {
|
||||
secret, err := generateSecret()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := AuthConfig{
|
||||
JWTSecret: base64.StdEncoding.EncodeToString(secret),
|
||||
Issuer: "sproutgate",
|
||||
}
|
||||
if err := writeJSONFile(s.authConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.jwtSecret = secret
|
||||
s.issuer = cfg.Issuer
|
||||
return nil
|
||||
}
|
||||
var cfg AuthConfig
|
||||
if err := readJSONFile(s.authConfigPath, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
secretBytes, err := base64.StdEncoding.DecodeString(cfg.JWTSecret)
|
||||
if err != nil || len(secretBytes) == 0 {
|
||||
secretBytes, err = generateSecret()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.JWTSecret = base64.StdEncoding.EncodeToString(secretBytes)
|
||||
if strings.TrimSpace(cfg.Issuer) == "" {
|
||||
cfg.Issuer = "sproutgate"
|
||||
}
|
||||
if err := writeJSONFile(s.authConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(cfg.Issuer) == "" {
|
||||
cfg.Issuer = "sproutgate"
|
||||
if err := writeJSONFile(s.authConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.jwtSecret = secretBytes
|
||||
s.issuer = cfg.Issuer
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) loadOrCreateEmailConfig() error {
|
||||
if _, err := os.Stat(s.emailConfigPath); errors.Is(err, os.ErrNotExist) {
|
||||
cfg := EmailConfig{
|
||||
FromName: "萌芽账户认证中心",
|
||||
FromAddress: "notice@smyhub.com",
|
||||
Username: "",
|
||||
Password: "tyh@19900420",
|
||||
SMTPHost: "smtp.qiye.aliyun.com",
|
||||
SMTPPort: 465,
|
||||
Encryption: "SSL",
|
||||
}
|
||||
if err := writeJSONFile(s.emailConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Username == "" {
|
||||
cfg.Username = cfg.FromAddress
|
||||
}
|
||||
s.emailConfig = cfg
|
||||
return nil
|
||||
}
|
||||
var cfg EmailConfig
|
||||
if err := readJSONFile(s.emailConfigPath, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(cfg.FromName) == "" {
|
||||
cfg.FromName = "萌芽账户认证中心"
|
||||
}
|
||||
if strings.TrimSpace(cfg.FromAddress) == "" {
|
||||
cfg.FromAddress = "notice@smyhub.com"
|
||||
}
|
||||
if strings.TrimSpace(cfg.Username) == "" {
|
||||
cfg.Username = cfg.FromAddress
|
||||
}
|
||||
if strings.TrimSpace(cfg.SMTPHost) == "" {
|
||||
cfg.SMTPHost = "smtp.qiye.aliyun.com"
|
||||
}
|
||||
if cfg.SMTPPort == 0 {
|
||||
cfg.SMTPPort = 465
|
||||
}
|
||||
if strings.TrimSpace(cfg.Encryption) == "" {
|
||||
cfg.Encryption = "SSL"
|
||||
}
|
||||
if err := writeJSONFile(s.emailConfigPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.emailConfig = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateSecret() ([]byte, error) {
|
||||
secret := make([]byte, 32)
|
||||
_, err := rand.Read(secret)
|
||||
return secret, err
|
||||
}
|
||||
|
||||
func (s *Store) ListUsers() ([]models.UserRecord, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
entries, err := os.ReadDir(s.usersDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users := make([]models.UserRecord, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
var record models.UserRecord
|
||||
path := filepath.Join(s.usersDir, entry.Name())
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, record)
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetUser(account string) (models.UserRecord, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.userFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return models.UserRecord{}, false, nil
|
||||
}
|
||||
var record models.UserRecord
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.UserRecord{}, false, err
|
||||
}
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateUser(record models.UserRecord) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.userFilePath(record.Account)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return errors.New("account already exists")
|
||||
}
|
||||
if record.CreatedAt == "" {
|
||||
record.CreatedAt = models.NowISO()
|
||||
}
|
||||
record.UpdatedAt = record.CreatedAt
|
||||
return writeJSONFile(path, record)
|
||||
}
|
||||
|
||||
func (s *Store) SaveUser(record models.UserRecord) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.userFilePath(record.Account)
|
||||
record.UpdatedAt = models.NowISO()
|
||||
return writeJSONFile(path, record)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteUser(account string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.userFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
func (s *Store) userFilePath(account string) string {
|
||||
return filepath.Join(s.usersDir, userFileName(account))
|
||||
}
|
||||
|
||||
func userFileName(account string) string {
|
||||
encoded := base64.RawURLEncoding.EncodeToString([]byte(account))
|
||||
return encoded + ".json"
|
||||
}
|
||||
|
||||
func readJSONFile(path string, target any) error {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(raw, target)
|
||||
}
|
||||
|
||||
func writeJSONFile(path string, value any) error {
|
||||
raw, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, raw, 0644)
|
||||
}
|
||||
Reference in New Issue
Block a user