Files
MindSift/internal/auth/auth.go
T
sandrodachandClaude Opus 5.5 b5f926bcf7 fix(auth): new passwords need at least 8 characters (FLAW-09)
auth.ValidatePassword checks every way a password is set: setup,
registration, profile, admin create and reset, reset by mail. Same rule
in the browser before sending (lib/password.ts), with our own German
message instead of the browser popup. Existing shorter passwords still
log in. Profile and admin reset no longer trim the password, which made
passwords with surrounding spaces unusable for login.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:20:46 +01:00

129 lines
3.4 KiB
Go

package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
type contextKey string
const UserIDContextKey contextKey = "mindsift_user_id"
type Claims struct {
UserID string `json:"userId"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
TokenVersion int `json:"tokenVersion"`
jwt.RegisteredClaims
}
// MinPasswordLength applies wherever a password is set (setup, registration, profile, admin, reset).
// Existing shorter passwords keep working for login.
const MinPasswordLength = 8
var (
ErrPasswordTooShort = fmt.Errorf("Das Passwort muss mindestens %d Zeichen lang sein", MinPasswordLength)
// bcrypt only uses the first 72 bytes and rejects longer input
ErrPasswordTooLong = errors.New("Das Passwort darf höchstens 72 Bytes lang sein")
)
// ValidatePassword checks a new password: at least MinPasswordLength characters, not only whitespace.
func ValidatePassword(password string) error {
if strings.TrimSpace(password) == "" || utf8.RuneCountInString(password) < MinPasswordLength {
return ErrPasswordTooShort
}
if len(password) > 72 {
return ErrPasswordTooLong
}
return nil
}
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
func CheckPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
func GenerateToken(secret, userID, email, displayName string, tokenVersion int) (string, error) {
if tokenVersion <= 0 {
tokenVersion = 1
}
b := make([]byte, 16)
_, _ = rand.Read(b)
jti := hex.EncodeToString(b)
now := time.Now()
claims := &Claims{
UserID: userID,
Email: email,
DisplayName: displayName,
TokenVersion: tokenVersion,
RegisteredClaims: jwt.RegisteredClaims{
ID: jti,
ExpiresAt: jwt.NewNumericDate(now.Add(30 * 24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
Issuer: "mindsift",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
func ValidateToken(secret, tokenStr string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(secret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, errors.New("invalid token claims")
}
return claims, nil
}
// GenerateAPIKey creates a new webhook/ingestion API key (e.g. mindsift_sec_...) and returns rawKey, prefix, sha256Hash
func GenerateAPIKey() (string, string, string) {
b := make([]byte, 18)
_, _ = rand.Read(b)
raw := "mindsift_sec_" + hex.EncodeToString(b)
prefix := raw[:20] + "..."
return raw, prefix, HashAPIKey(raw)
}
func HashAPIKey(rawKey string) string {
sum := sha256.Sum256([]byte(rawKey))
return hex.EncodeToString(sum[:])
}
func WithUserID(ctx context.Context, userID string) context.Context {
return context.WithValue(ctx, UserIDContextKey, userID)
}
func GetUserID(ctx context.Context) string {
if val, ok := ctx.Value(UserIDContextKey).(string); ok {
return val
}
return ""
}