backend barebone done, big chunk of work done
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
// Package auth provides cryptographic core for authentication.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
// argon2Memory, argon2Time, argon2Threads, argon2KeyLen, and saltLen are parameters
|
||||
// recommended by OWASP for Argon2id (memory=65536 KiB, time=3 iterations, parallelism=2).
|
||||
const (
|
||||
argon2Memory = 65536
|
||||
argon2Time = 3
|
||||
argon2Threads = 2
|
||||
argon2KeyLen = 32
|
||||
saltLen = 16
|
||||
)
|
||||
|
||||
// HashPassword generates an Argon2id hash of the provided password and returns it
|
||||
// in PHC string format: $argon2id$v=19$m=...,t=...,p=...$<salt>$<hash>.
|
||||
func HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, saltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("auth: generate salt: %w", err)
|
||||
}
|
||||
hash := argon2.IDKey([]byte(password), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen)
|
||||
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
|
||||
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
|
||||
phc := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, argon2Memory, argon2Time, argon2Threads, b64Salt, b64Hash)
|
||||
return phc, nil
|
||||
}
|
||||
|
||||
// VerifyPassword checks whether the provided password matches the given Argon2id PHC hash.
|
||||
// It returns (true, nil) on match, (false, nil) on mismatch, or (false, error) on invalid format.
|
||||
func VerifyPassword(phc, password string) (bool, error) {
|
||||
parts := strings.Split(phc, "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||
return false, errors.New("auth: invalid hash format")
|
||||
}
|
||||
// Version
|
||||
vPart := parts[2]
|
||||
if !strings.HasPrefix(vPart, "v=") {
|
||||
return false, errors.New("auth: invalid version format")
|
||||
}
|
||||
v, err := strconv.Atoi(strings.TrimPrefix(vPart, "v="))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("auth: parse version: %w", err)
|
||||
}
|
||||
if v != argon2.Version {
|
||||
return false, errors.New("auth: incompatible version")
|
||||
}
|
||||
// Parameters
|
||||
mem, t, p := uint32(0), uint32(0), uint32(0)
|
||||
for _, kv := range strings.Split(parts[3], ",") {
|
||||
pair := strings.SplitN(kv, "=", 2)
|
||||
if len(pair) != 2 {
|
||||
return false, errors.New("auth: invalid parameter format")
|
||||
}
|
||||
key, val := pair[0], pair[1]
|
||||
switch key {
|
||||
case "m":
|
||||
x, err := strconv.ParseUint(val, 10, 32)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("auth: parse memory: %w", err)
|
||||
}
|
||||
mem = uint32(x)
|
||||
case "t":
|
||||
x, err := strconv.ParseUint(val, 10, 32)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("auth: parse time: %w", err)
|
||||
}
|
||||
t = uint32(x)
|
||||
case "p":
|
||||
x, err := strconv.ParseUint(val, 10, 32)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("auth: parse threads: %w", err)
|
||||
}
|
||||
p = uint32(x)
|
||||
default:
|
||||
return false, fmt.Errorf("auth: unknown parameter %q", key)
|
||||
}
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("auth: decode salt: %w", err)
|
||||
}
|
||||
expected, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("auth: decode hash: %w", err)
|
||||
}
|
||||
computed := argon2.IDKey([]byte(password), salt, t, mem, uint8(p), uint32(len(expected)))
|
||||
if subtle.ConstantTimeCompare(computed, expected) == 1 {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Package auth_test tests password hashing and verification.
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"messenger/internal/auth"
|
||||
)
|
||||
|
||||
func TestHashAndVerify(t *testing.T) {
|
||||
pwd := "secret"
|
||||
hash, err := auth.HashPassword(pwd)
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword error: %v", err)
|
||||
}
|
||||
ok, err := auth.VerifyPassword(hash, pwd)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected password to verify")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongPassword(t *testing.T) {
|
||||
hash, _ := auth.HashPassword("correct")
|
||||
ok, err := auth.VerifyPassword(hash, "wrong")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword error on wrong password: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("wrong password should not verify")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidHashFormat(t *testing.T) {
|
||||
_, err := auth.VerifyPassword("invalidformat", "pwd")
|
||||
if err == nil {
|
||||
t.Fatal("expected error on invalid hash format")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDifferentSalts(t *testing.T) {
|
||||
h1, _ := auth.HashPassword("pass")
|
||||
h2, _ := auth.HashPassword("pass")
|
||||
if h1 == h2 {
|
||||
t.Fatal("expected different hashes for same password due to salt")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Package auth provides cryptographic core for authentication.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// GenerateRefreshToken generates a new opaque refresh token (raw) and its SHA256 hash.
|
||||
// The raw token is 32 random bytes hex-encoded; the hash is hex(SHA256(raw)).
|
||||
func GenerateRefreshToken() (raw, hash string, err error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err = rand.Read(b); err != nil {
|
||||
return "", "", fmt.Errorf("auth: generate refresh token: %w", err)
|
||||
}
|
||||
raw = hex.EncodeToString(b)
|
||||
hash = HashRefreshToken(raw)
|
||||
return raw, hash, nil
|
||||
}
|
||||
|
||||
// HashRefreshToken returns the SHA256 hash (hex) of the raw refresh token.
|
||||
func HashRefreshToken(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Package auth_test tests refresh token generation and hashing.
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"messenger/internal/auth"
|
||||
)
|
||||
|
||||
func TestGenerateAndHash(t *testing.T) {
|
||||
raw, hash, err := auth.GenerateRefreshToken()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRefreshToken error: %v", err)
|
||||
}
|
||||
if raw == hash {
|
||||
t.Fatal("raw and hash should differ")
|
||||
}
|
||||
if auth.HashRefreshToken(raw) != hash {
|
||||
t.Fatal("HashRefreshToken mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRandomness(t *testing.T) {
|
||||
r1, _, _ := auth.GenerateRefreshToken()
|
||||
r2, _, _ := auth.GenerateRefreshToken()
|
||||
if r1 == r2 {
|
||||
t.Fatal("expected two different raw tokens")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawLength(t *testing.T) {
|
||||
raw, _, err := auth.GenerateRefreshToken()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRefreshToken error: %v", err)
|
||||
}
|
||||
if len(raw) != 64 {
|
||||
t.Fatalf("expected raw length 64, got %d", len(raw))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Package auth provides cryptographic core for authentication.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// ErrInvalidToken is returned when a token is invalid or cannot be parsed.
|
||||
var ErrInvalidToken = errors.New("auth: invalid token")
|
||||
|
||||
// TokenManager handles issuing and parsing JWT access tokens.
|
||||
type TokenManager struct {
|
||||
secret []byte
|
||||
}
|
||||
|
||||
// NewTokenManager creates a TokenManager using the provided secret key.
|
||||
func NewTokenManager(secret string) *TokenManager {
|
||||
return &TokenManager{secret: []byte(secret)}
|
||||
}
|
||||
|
||||
// IssueAccessToken issues a signed JWT access token for the given userID with TTL.
|
||||
func (t *TokenManager) IssueAccessToken(userID int64, ttl time.Duration) (string, error) {
|
||||
now := time.Now()
|
||||
claims := jwt.RegisteredClaims{
|
||||
Subject: strconv.FormatInt(userID, 10),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(t.secret)
|
||||
}
|
||||
|
||||
// ParseAccessToken parses and validates the JWT access token, returning the userID.
|
||||
func (t *TokenManager) ParseAccessToken(tokenStr string) (int64, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if token.Method != jwt.SigningMethodHS256 {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
return t.secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, ErrInvalidToken
|
||||
}
|
||||
claims, ok := token.Claims.(*jwt.RegisteredClaims)
|
||||
if !ok || !token.Valid {
|
||||
return 0, ErrInvalidToken
|
||||
}
|
||||
userID, err := strconv.ParseInt(claims.Subject, 10, 64)
|
||||
if err != nil {
|
||||
return 0, ErrInvalidToken
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Package auth_test tests access token issuance and parsing.
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"messenger/internal/auth"
|
||||
)
|
||||
|
||||
func TestIssueParseRoundTrip(t *testing.T) {
|
||||
secret := "0123456789abcdef0123456789abcdef"
|
||||
tm := auth.NewTokenManager(secret)
|
||||
token, err := tm.IssueAccessToken(42, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueAccessToken error: %v", err)
|
||||
}
|
||||
uid, err := tm.ParseAccessToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAccessToken error: %v", err)
|
||||
}
|
||||
if uid != 42 {
|
||||
t.Fatalf("expected userID 42, got %d", uid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidSignature(t *testing.T) {
|
||||
tm1 := auth.NewTokenManager("secret1secret1secret1secret1sec1")
|
||||
tm2 := auth.NewTokenManager("secret2secret2secret2secret2sec2")
|
||||
token, err := tm1.IssueAccessToken(1, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueAccessToken error: %v", err)
|
||||
}
|
||||
_, err = tm2.ParseAccessToken(token)
|
||||
if !errors.Is(err, auth.ErrInvalidToken) {
|
||||
t.Fatalf("expected ErrInvalidToken, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiredToken(t *testing.T) {
|
||||
tm := auth.NewTokenManager("secretsecretsecretsecretsecretse")
|
||||
token, err := tm.IssueAccessToken(1, -time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueAccessToken error: %v", err)
|
||||
}
|
||||
_, err = tm.ParseAccessToken(token)
|
||||
if !errors.Is(err, auth.ErrInvalidToken) {
|
||||
t.Fatalf("expected ErrInvalidToken for expired, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoneAlgorithmToken(t *testing.T) {
|
||||
claims := jwt.RegisteredClaims{
|
||||
Subject: "1",
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
|
||||
tokenStr, err := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
|
||||
if err != nil {
|
||||
t.Fatalf("create none token: %v", err)
|
||||
}
|
||||
tm := auth.NewTokenManager("longsecretlongsecretlongsecretlongsec")
|
||||
_, err = tm.ParseAccessToken(tokenStr)
|
||||
if !errors.Is(err, auth.ErrInvalidToken) {
|
||||
t.Fatalf("expected ErrInvalidToken for none alg, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGarbageToken(t *testing.T) {
|
||||
tm := auth.NewTokenManager("secretsecretsecretsecretsecretse")
|
||||
_, err := tm.ParseAccessToken("not a token")
|
||||
if !errors.Is(err, auth.ErrInvalidToken) {
|
||||
t.Fatalf("expected ErrInvalidToken for garbage token, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user