feat: protect write endpoints with JWT auth (story #08)

Add bcrypt user file auth, JWT middleware on all write routes, Pinia
authStore with localStorage persistence, login view with redirect
support, and v-if guards on all CRUD controls and admin nav link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 11:18:42 +02:00
co-authored by Claude Sonnet 4.6
parent 649c9687ac
commit 1b381d9385
32 changed files with 895 additions and 56 deletions
+83
View File
@@ -0,0 +1,83 @@
package auth
import (
"bufio"
"errors"
"os"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
// Claims is the JWT payload.
type Claims struct {
Username string `json:"username"`
jwt.RegisteredClaims
}
// LoadUsers reads username:bcrypt_hash pairs from path.
// Lines starting with '#' and blank lines are ignored.
func LoadUsers(path string) (map[string]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
users := make(map[string]string)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
users[parts[0]] = parts[1]
}
return users, scanner.Err()
}
// VerifyPassword checks username+password against the bcrypt hash stored for that user.
// The peppered input is "username:password".
func VerifyPassword(users map[string]string, username, password string) bool {
hash, ok := users[username]
if !ok {
return false
}
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(username+":"+password)) == nil
}
// GenerateJWT creates a signed HS256 token for username, valid for 1 hour.
func GenerateJWT(username, secret string) (string, error) {
claims := Claims{
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
}
// ValidateJWT parses and verifies token, returning its claims.
func ValidateJWT(tokenStr, secret string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
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")
}
return claims, nil
}
+125
View File
@@ -0,0 +1,125 @@
package auth_test
import (
"os"
"path/filepath"
"testing"
"golang.org/x/crypto/bcrypt"
"osdb/internal/auth"
)
// hashPepper mirrors the pepper convention: bcrypt("username:password")
func hashPepper(username, password string) string {
h, err := bcrypt.GenerateFromPassword([]byte(username+":"+password), bcrypt.MinCost)
if err != nil {
panic(err)
}
return string(h)
}
func writeTempUsersFile(t *testing.T, content string) string {
t.Helper()
f, err := os.CreateTemp(t.TempDir(), "users*.env")
if err != nil {
t.Fatal(err)
}
if _, err := f.WriteString(content); err != nil {
t.Fatal(err)
}
f.Close()
return f.Name()
}
func TestLoadUsers_ParsesValidFile(t *testing.T) {
hash := hashPepper("admin", "secret")
path := writeTempUsersFile(t, "admin:"+hash+"\n")
users, err := auth.LoadUsers(path)
if err != nil {
t.Fatalf("LoadUsers: %v", err)
}
if len(users) != 1 {
t.Fatalf("expected 1 user, got %d", len(users))
}
if _, ok := users["admin"]; !ok {
t.Error("expected 'admin' in users map")
}
}
func TestLoadUsers_SkipsCommentLines(t *testing.T) {
hash := hashPepper("admin", "secret")
content := "# comment\nadmin:" + hash + "\n"
path := writeTempUsersFile(t, content)
users, err := auth.LoadUsers(path)
if err != nil {
t.Fatalf("LoadUsers: %v", err)
}
if len(users) != 1 {
t.Fatalf("expected 1 user, got %d", len(users))
}
}
func TestLoadUsers_MissingFile(t *testing.T) {
_, err := auth.LoadUsers(filepath.Join(t.TempDir(), "nonexistent.env"))
if err == nil {
t.Error("expected error for missing file")
}
}
func TestVerifyPassword_CorrectPassword(t *testing.T) {
hash := hashPepper("admin", "secret")
users := map[string]string{"admin": hash}
if !auth.VerifyPassword(users, "admin", "secret") {
t.Error("expected password to match")
}
}
func TestVerifyPassword_WrongPassword(t *testing.T) {
hash := hashPepper("admin", "secret")
users := map[string]string{"admin": hash}
if auth.VerifyPassword(users, "admin", "wrong") {
t.Error("expected password to not match")
}
}
func TestVerifyPassword_UnknownUser(t *testing.T) {
if auth.VerifyPassword(map[string]string{}, "nobody", "secret") {
t.Error("expected false for unknown user")
}
}
func TestGenerateAndValidateJWT(t *testing.T) {
token, err := auth.GenerateJWT("admin", "testsecret")
if err != nil {
t.Fatalf("GenerateJWT: %v", err)
}
if token == "" {
t.Error("expected non-empty token")
}
claims, err := auth.ValidateJWT(token, "testsecret")
if err != nil {
t.Fatalf("ValidateJWT: %v", err)
}
if claims.Username != "admin" {
t.Errorf("expected username 'admin', got %q", claims.Username)
}
}
func TestValidateJWT_WrongSecret(t *testing.T) {
token, _ := auth.GenerateJWT("admin", "correct")
if _, err := auth.ValidateJWT(token, "wrong"); err == nil {
t.Error("expected error for wrong secret")
}
}
func TestValidateJWT_MalformedToken(t *testing.T) {
if _, err := auth.ValidateJWT("not.a.token", "secret"); err == nil {
t.Error("expected error for malformed token")
}
}