Files
alterminal/internal/auth/user.go
T
2026-10-03 10:44:29 +08:00

111 lines
3.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"time"
"golang.org/x/crypto/argon2"
)
// Role 為使用者角色:admin 具管理權限,user 為一般權限。
type Role string
// 允許的角色值。
const (
RoleAdmin Role = "admin"
RoleUser Role = "user"
)
// Valid 回傳角色是否為允許的值。
func (r Role) Valid() bool {
return r == RoleAdmin || r == RoleUser
}
// User 為使用者帳號模型,對應 users 資料表。
// Username 與 Email 皆為唯一;密碼以 argon2id(PHC 格式)雜湊儲存,永不存明文。
type User struct {
ID uint `gorm:"primaryKey"`
Username string `gorm:"uniqueIndex;size:64;not null"` // 登入帳號
Email string `gorm:"uniqueIndex;size:255;not null"` // OIDC email scope
EmailVerified bool `gorm:"not null;default:false"` // OIDC email_verified claim
PasswordHash string `gorm:"size:255;not null"` // argon2id PHC 字串
Name string `gorm:"size:255"` // 顯示名稱(profile scope 的 name claim)
Role Role `gorm:"size:16;not null;default:user"` // admin 或 user
CreatedAt time.Time
UpdatedAt time.Time
}
// SetPassword 以 argon2id 雜湊密碼並寫入 PasswordHash。
func (u *User) SetPassword(password string) error {
hash, err := HashPassword(password)
if err != nil {
return err
}
u.PasswordHash = hash
return nil
}
// CheckPassword 回傳密碼是否與 PasswordHash 相符;雜湊格式無效時一律視為不相符。
func (u *User) CheckPassword(password string) bool {
ok, err := VerifyPassword(password, u.PasswordHash)
return err == nil && ok
}
// 參數採 OWASP 對 Argon2id 的建議:m=19 MiB、t=2、p=1,salt 16 bytes、key 32 bytes。
const (
argon2MemoryKB = 19 * 1024
argon2Time = 2
argon2Threads = 1
argon2SaltLen = 16
argon2KeyLen = 32
)
// HashPassword 產生格式如 $argon2id$v=19$m=19456,t=2,p=1$<salt>$<key> 的 PHC 字串。
func HashPassword(password string) (string, error) {
salt := make([]byte, argon2SaltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("read salt: %w", err)
}
key := argon2.IDKey([]byte(password), salt, argon2Time, argon2MemoryKB, argon2Threads, argon2KeyLen)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argon2MemoryKB, argon2Time, argon2Threads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(key),
), nil
}
// VerifyPassword 解析 PHC 字串並以 constant-time 比對重算結果。
func VerifyPassword(password, encoded string) (bool, error) {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false, errors.New("malformed password hash")
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return false, fmt.Errorf("parse version: %w", err)
}
if version != argon2.Version {
return false, fmt.Errorf("unsupported argon2id version %d", version)
}
var memoryKB, timeCost uint32
var threads uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memoryKB, &timeCost, &threads); err != nil {
return false, fmt.Errorf("parse parameters: %w", err)
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, fmt.Errorf("decode salt: %w", err)
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, fmt.Errorf("decode key: %w", err)
}
got := argon2.IDKey([]byte(password), salt, timeCost, memoryKB, threads, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1, nil
}