Files
cmautobuy/admin/service/auth.go
T

154 lines
5.1 KiB
Go

// Admin 网页认证:首次管理员、密码校验、随机 Session 和过期判断。
package service
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
const (
WebSessionDuration = 12 * time.Hour
minimumPasswordLen = 8
maxUsernameLen = 64
)
var (
ErrInvalidCredentials = errors.New("用户名或密码错误")
ErrUnauthenticated = errors.New("未登录或登录已过期")
// 用户不存在时也跑一次 bcrypt,避免响应时间直接泄露“这个用户名存在”。
dummyPasswordHash, _ = bcrypt.GenerateFromPassword([]byte("not-a-real-password"), bcrypt.DefaultCost)
)
// HasUsers 判断首次初始化入口是否已经永久关闭。
func HasUsers(db *sql.DB) (bool, error) {
count, err := repository.CountUsers(db)
return count > 0, err
}
// SetupInitialAdmin 校验表单、哈希密码并在事务中创建首位管理员。
func SetupInitialAdmin(db *sql.DB, username, password, confirmation string, now time.Time) error {
username = strings.TrimSpace(username)
if username == "" {
return fmt.Errorf("用户名不能为空")
}
if len([]rune(username)) > maxUsernameLen {
return fmt.Errorf("用户名不能超过 %d 个字符", maxUsernameLen)
}
if len([]rune(password)) < minimumPasswordLen {
return fmt.Errorf("密码至少需要 %d 个字符", minimumPasswordLen)
}
// bcrypt 最多接受 72 字节。中文等字符可能占多个字节,因此不能只靠
// HTML 的 maxlength;服务端需要在哈希前给出可理解的校验错误。
if len([]byte(password)) > 72 {
return fmt.Errorf("密码不能超过 72 个字节")
}
if password != confirmation {
return fmt.Errorf("两次输入的密码不一致")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("生成密码哈希失败: %w", err)
}
userID, err := randomID("USR-", 16)
if err != nil {
return err
}
at := now.UTC().Format(model.TimeLayout)
return repository.CreateInitialAdmin(db, model.User{
UserID: userID, Username: username, PasswordHash: string(hash),
Role: model.RoleAdmin, Status: model.UserActive,
PasswordChangedAt: at, CreatedAt: at, UpdatedAt: at,
})
}
// Login 校验统一凭据并创建一个固定 12 小时有效的 Session。
// 返回的 token 原文只交给 Cookie,数据库仅保存 SHA-256。
func Login(db *sql.DB, username, password string, now time.Time) (token string, user *model.User, expiresAt time.Time, err error) {
username = strings.TrimSpace(username)
user, findErr := repository.FindUserByUsername(db, username)
hash := dummyPasswordHash
if findErr == nil {
hash = []byte(user.PasswordHash)
} else if !errors.Is(findErr, repository.ErrUserNotFound) {
return "", nil, time.Time{}, findErr
}
passwordOK := bcrypt.CompareHashAndPassword(hash, []byte(password)) == nil
if findErr != nil || !passwordOK || user.Status != model.UserActive {
return "", nil, time.Time{}, ErrInvalidCredentials
}
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return "", nil, time.Time{}, fmt.Errorf("生成 Web Session 失败: %w", err)
}
token = base64.RawURLEncoding.EncodeToString(tokenBytes)
expiresAt = now.Add(WebSessionDuration).UTC()
at := now.UTC().Format(model.TimeLayout)
if err := repository.DeleteExpiredSessions(db, at); err != nil {
return "", nil, time.Time{}, err
}
if err := repository.CreateLoginSession(db, model.WebSession{
SessionHash: SessionTokenHash(token), UserID: user.UserID,
ExpiresAt: expiresAt.Format(model.TimeLayout), CreatedAt: at, LastSeenAt: at,
}, at); err != nil {
return "", nil, time.Time{}, err
}
return token, user, expiresAt, nil
}
// Authenticate 验证 Cookie Token、固定过期时间和账号状态。
func Authenticate(db *sql.DB, token string, now time.Time) (*model.User, error) {
if strings.TrimSpace(token) == "" {
return nil, ErrUnauthenticated
}
session, user, err := repository.FindUserBySessionHash(db, SessionTokenHash(token))
if errors.Is(err, repository.ErrSessionNotFound) {
return nil, ErrUnauthenticated
}
if err != nil {
return nil, err
}
expiresAt, ok := model.ParseISO(session.ExpiresAt)
if !ok || !now.UTC().Before(expiresAt) || user.Status != model.UserActive {
_ = repository.DeleteSession(db, session.SessionHash)
return nil, ErrUnauthenticated
}
return user, nil
}
// Logout 撤销当前 Token 对应的服务端 Session。空 Token 也视为成功。
func Logout(db *sql.DB, token string) error {
if token == "" {
return nil
}
return repository.DeleteSession(db, SessionTokenHash(token))
}
// SessionTokenHash 返回数据库可保存的 Token SHA-256 十六进制文本。
func SessionTokenHash(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func randomID(prefix string, byteCount int) (string, error) {
buf := make([]byte, byteCount)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("生成用户编号失败: %w", err)
}
return prefix + hex.EncodeToString(buf), nil
}