material_manage_system/internal/service/auth.go

303 lines
8.2 KiB
Go

package service
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"sync"
"time"
"github.com/wmms/wmms/internal/model"
"github.com/wmms/wmms/internal/repository"
"github.com/wmms/wmms/pkg/database"
"golang.org/x/crypto/bcrypt"
)
var (
ErrInvalidCredentials = errors.New("invalid username or password")
ErrUserNotFound = errors.New("user not found")
)
type AuthService struct {
userRepo *repository.UserRepository
}
func NewAuthService() *AuthService {
return &AuthService{
userRepo: repository.NewUserRepository(),
}
}
// SimpleToken represents a simple JWT-like token
type SimpleToken struct {
SessionID string `json:"session_id,omitempty"`
UserID int64 `json:"user_id"`
Username string `json:"username"`
Roles []string `json:"roles"`
Exp int64 `json:"exp"`
}
// GenerateToken creates a simple token for the user
func (s *AuthService) GenerateToken(user *model.User) (string, error) {
nonce := make([]byte, 32)
if _, err := rand.Read(nonce); err != nil {
return "", err
}
exp := time.Now().Add(24 * time.Hour).Unix()
roleCodes := make([]string, 0, len(user.Roles))
for _, role := range user.Roles {
if role.Enabled {
roleCodes = append(roleCodes, role.Code)
}
}
token := SimpleToken{
SessionID: base64.RawURLEncoding.EncodeToString(nonce),
UserID: user.ID,
Username: user.Username,
Roles: roleCodes,
Exp: exp,
}
// Create header
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
// Create payload
payloadBytes, err := json.Marshal(token)
if err != nil {
return "", err
}
payload := base64.RawURLEncoding.EncodeToString(payloadBytes)
// Create signature using HMAC-SHA256 (cryptographically secure)
signatureInput := header + "." + payload
mac := hmac.New(sha256.New, []byte("warehouse-mms-secret-key"))
mac.Write([]byte(signatureInput))
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return header + "." + payload + "." + signature, nil
}
// ValidateToken validates a token and returns the user info
func (s *AuthService) ValidateToken(tokenString string) (*SimpleToken, error) {
parts := splitToken(tokenString)
if len(parts) != 3 {
return nil, errors.New("invalid token format")
}
header, payload, signature := parts[0], parts[1], parts[2]
// Verify signature using HMAC-SHA256 (cryptographically secure)
signatureInput := header + "." + payload
mac := hmac.New(sha256.New, []byte("warehouse-mms-secret-key"))
mac.Write([]byte(signatureInput))
expectedSig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(signature), []byte(expectedSig)) {
return nil, errors.New("invalid token signature")
}
// Decode payload
payloadBytes, err := base64.RawURLEncoding.DecodeString(payload)
if err != nil {
return nil, err
}
var token SimpleToken
if err := json.Unmarshal(payloadBytes, &token); err != nil {
return nil, err
}
// Check expiration
if time.Now().Unix() >= token.Exp {
return nil, errors.New("token expired")
}
if err := ValidateSession(TokenSessionID(tokenString), token.Exp); err != nil {
return nil, err
}
return &token, nil
}
// The single-server session cache is restored from revoked_tokens on startup.
// Keep only a fingerprint, never a raw bearer token, and expire entries with JWTs.
var revokedSessions = struct {
sync.Mutex
entries map[string]int64
}{entries: make(map[string]int64)}
func TokenSessionID(token string) string {
sum := sha256.Sum256([]byte(token))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
func ValidateSession(id string, expiresAt int64) error {
if id == "" || time.Now().Unix() >= expiresAt {
return errors.New("session expired")
}
revokedSessions.Lock()
defer revokedSessions.Unlock()
if _, revoked := revokedSessions.entries[id]; revoked {
return errors.New("session revoked")
}
return nil
}
func (s *AuthService) RevokeSession(value string) error {
token, err := s.ValidateToken(value)
if err != nil {
return err
}
if _, err := database.DB.Exec(`INSERT INTO revoked_tokens (jti, user_id, expires_at) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE expires_at=VALUES(expires_at)`, TokenSessionID(value), token.UserID, time.Unix(token.Exp, 0)); err != nil {
return err
}
revokedSessions.Lock()
defer revokedSessions.Unlock()
for id, expires := range revokedSessions.entries {
if expires <= time.Now().Unix() {
delete(revokedSessions.entries, id)
}
}
revokedSessions.entries[TokenSessionID(value)] = token.Exp
return nil
}
// LoadRevokedSessions runs before serving requests, so restarting the process
// cannot reactivate a logged-out token. Tickets remain local to this server.
func LoadRevokedSessions() error {
// Bind the cutoff through the same driver location as the stored DATETIME.
// MySQL NOW() may use a different session timezone and omit live revocations.
rows, err := database.DB.Query(`SELECT jti, expires_at FROM revoked_tokens WHERE expires_at > ?`, time.Now())
if err != nil {
return err
}
defer rows.Close()
entries := make(map[string]int64)
for rows.Next() {
var id string
var expiry time.Time
if err := rows.Scan(&id, &expiry); err != nil {
return err
}
entries[id] = expiry.Unix()
}
if err := rows.Err(); err != nil {
return err
}
revokedSessions.Lock()
revokedSessions.entries = entries
revokedSessions.Unlock()
return nil
}
func splitToken(token string) []string {
var parts []string
start := 0
for i := 0; i < len(token); i++ {
if token[i] == '.' {
parts = append(parts, token[start:i])
start = i + 1
}
}
parts = append(parts, token[start:])
return parts
}
// Login authenticates a user and returns a token
func (s *AuthService) Login(username, password string) (*model.LoginResponse, error) {
user, err := s.userRepo.GetByUsername(username)
if err != nil {
return nil, ErrInvalidCredentials
}
// Verify password
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
// Generate token
token, err := s.GenerateToken(user)
if err != nil {
return nil, err
}
return &model.LoginResponse{
Token: token,
User: *user,
}, nil
}
// GetUserByID returns a user by ID
func (s *AuthService) GetUserByID(id int64) (*model.User, error) {
return s.userRepo.GetByID(id)
}
// ListUsers returns all users with pagination
func (s *AuthService) ListUsers(search string, limit, offset int) ([]model.User, int64, error) {
return s.userRepo.List(search, limit, offset)
}
// CreateUser creates a new user
func (s *AuthService) CreateUser(username, password, description string, roleIDs []int64, organization ...model.UserOrganizationInput) (*model.User, error) {
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
user := &model.User{
Username: username,
Password: string(hashedPassword),
Description: description,
}
// Service callers always validate company access. Repository calls without
// input are reserved for the legacy bootstrap users created before migration.
input := model.UserOrganizationInput{}
if len(organization) > 0 {
input = organization[0]
}
if err := s.userRepo.Create(user, roleIDs, input); err != nil {
return nil, err
}
return s.userRepo.GetByID(user.ID)
}
// UpdateUser updates a user (admin function)
func (s *AuthService) UpdateUser(id int64, description string, roleIDs []int64, newPassword string, organization ...model.UserOrganizationInput) error {
user, err := s.userRepo.GetByID(id)
if err != nil {
return err
}
user.Description = description
if newPassword != "" {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
user.Password = string(hashedPassword)
return s.userRepo.UpdateWithPassword(user, roleIDs, organization...)
}
return s.userRepo.Update(user, roleIDs, organization...)
}
// DeleteUser deletes a user
func (s *AuthService) DeleteUser(id int64) error {
return s.userRepo.Delete(id)
}
// HashPassword hashes a password using bcrypt
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}