backend barebone done, big chunk of work done
CI / Go (build + vet + test) (push) Canceled after 0s
CI / Web (build + lint) (push) Canceled after 0s

This commit is contained in:
vantablackTea
2026-09-27 19:42:49 +03:00
parent 1a669c948a
commit c79a3cfe68
36 changed files with 2607 additions and 17 deletions
+103
View File
@@ -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
}
+49
View File
@@ -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")
}
}
+27
View File
@@ -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[:])
}
+39
View File
@@ -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))
}
}
+57
View File
@@ -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
}
+78
View File
@@ -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)
}
}