Files
task-master/internal/sqlite/auth.go
T
skrueckenandCursor 4adfcdb95a Add tm_server with SQLite, Svelte UI, and task-master TUI sync.
Introduces the web backend (auth, RBAC, project tokens, revisions),
YouTrack-or-taskmaster backend choice in the TUI, and a first-pass
embedded Svelte UI. The frontend is functional but needs a major rework.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 22:23:11 +01:00

281 lines
7.6 KiB
Go

package sqlite
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/argon2"
)
// Role constants for project membership.
const (
RoleOwner = "owner"
RoleAdmin = "admin"
RoleMember = "member"
RoleViewer = "viewer"
)
// User is a local account.
type User struct {
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"-"`
IsAdmin bool `json:"isAdmin"`
CreatedAt time.Time `json:"createdAt"`
}
// Member is a project membership.
type Member struct {
ProjectID string `json:"projectId"`
UserID string `json:"userId"`
Username string `json:"username,omitempty"`
Role string `json:"role"`
}
// HashPassword hashes a password with argon2id.
func HashPassword(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
return fmt.Sprintf("%s$%s",
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash),
), nil
}
// CheckPassword verifies a password against a stored hash.
func CheckPassword(password, encoded string) bool {
parts := strings.SplitN(encoded, "$", 2)
if len(parts) != 2 {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[0])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[1])
if err != nil {
return false
}
got := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
if len(got) != len(want) {
return false
}
var v byte
for i := range got {
v |= got[i] ^ want[i]
}
return v == 0
}
// CreateUser inserts a user.
func (db *DB) CreateUser(username, password string, isAdmin bool) (User, error) {
username = strings.TrimSpace(username)
if username == "" || password == "" {
return User{}, fmt.Errorf("username and password required")
}
hash, err := HashPassword(password)
if err != nil {
return User{}, err
}
u := User{
ID: uuid.NewString(),
Username: username,
PasswordHash: hash,
IsAdmin: isAdmin,
CreatedAt: time.Now().UTC(),
}
_, err = db.SQL.Exec(`INSERT INTO users(id, username, password_hash, is_admin, created_at) VALUES(?,?,?,?,?)`,
u.ID, u.Username, u.PasswordHash, boolInt(isAdmin), formatTime(u.CreatedAt))
if err != nil {
return User{}, fmt.Errorf("create user: %w", err)
}
return u, nil
}
// CountUsers returns the number of users.
func (db *DB) CountUsers() (int, error) {
var n int
err := db.SQL.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
return n, err
}
// GetUserByUsername looks up a user.
func (db *DB) GetUserByUsername(username string) (User, error) {
return db.scanUser(db.SQL.QueryRow(
`SELECT id, username, password_hash, is_admin, created_at FROM users WHERE username = ? COLLATE NOCASE`, username))
}
// GetUserByID looks up a user by id.
func (db *DB) GetUserByID(id string) (User, error) {
return db.scanUser(db.SQL.QueryRow(
`SELECT id, username, password_hash, is_admin, created_at FROM users WHERE id = ?`, id))
}
// ListUsers returns all users.
func (db *DB) ListUsers() ([]User, error) {
rows, err := db.SQL.Query(`SELECT id, username, password_hash, is_admin, created_at FROM users ORDER BY username COLLATE NOCASE`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []User
for rows.Next() {
u, err := db.scanUser(rows)
if err != nil {
return nil, err
}
u.PasswordHash = ""
out = append(out, u)
}
if out == nil {
out = []User{}
}
return out, rows.Err()
}
func (db *DB) scanUser(scanner interface{ Scan(dest ...any) error }) (User, error) {
var u User
var admin int
var created string
err := scanner.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &created)
if err == sql.ErrNoRows {
return User{}, fmt.Errorf("user not found")
}
if err != nil {
return User{}, err
}
u.IsAdmin = admin != 0
u.CreatedAt = parseTime(created)
return u, nil
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
// CreateSession creates a session and returns the raw token (only once).
func (db *DB) CreateSession(userID string, ttl time.Duration) (rawToken string, expiresAt time.Time, err error) {
if ttl <= 0 {
ttl = 7 * 24 * time.Hour
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", time.Time{}, err
}
rawToken = base64.RawURLEncoding.EncodeToString(buf)
sum := sha256.Sum256([]byte(rawToken))
tokenHash := base64.RawStdEncoding.EncodeToString(sum[:])
expiresAt = time.Now().UTC().Add(ttl)
_, err = db.SQL.Exec(`INSERT INTO sessions(id, user_id, token_hash, expires_at, created_at) VALUES(?,?,?,?,?)`,
uuid.NewString(), userID, tokenHash, formatTime(expiresAt), formatTime(time.Now().UTC()))
return rawToken, expiresAt, err
}
// UserFromSession resolves a session token to a user.
func (db *DB) UserFromSession(rawToken string) (User, error) {
sum := sha256.Sum256([]byte(rawToken))
tokenHash := base64.RawStdEncoding.EncodeToString(sum[:])
var userID, expires string
err := db.SQL.QueryRow(`SELECT user_id, expires_at FROM sessions WHERE token_hash = ?`, tokenHash).
Scan(&userID, &expires)
if err == sql.ErrNoRows {
return User{}, fmt.Errorf("invalid session")
}
if err != nil {
return User{}, err
}
if parseTime(expires).Before(time.Now().UTC()) {
_, _ = db.SQL.Exec(`DELETE FROM sessions WHERE token_hash = ?`, tokenHash)
return User{}, fmt.Errorf("session expired")
}
return db.GetUserByID(userID)
}
// DeleteSession removes a session by raw token.
func (db *DB) DeleteSession(rawToken string) error {
sum := sha256.Sum256([]byte(rawToken))
tokenHash := base64.RawStdEncoding.EncodeToString(sum[:])
_, err := db.SQL.Exec(`DELETE FROM sessions WHERE token_hash = ?`, tokenHash)
return err
}
// SetMember sets or updates a project membership.
func (db *DB) SetMember(projectID, userID, role string) error {
switch role {
case RoleOwner, RoleAdmin, RoleMember, RoleViewer:
default:
return fmt.Errorf("invalid role %q", role)
}
_, err := db.SQL.Exec(`
INSERT INTO project_members(project_id, user_id, role) VALUES(?,?,?)
ON CONFLICT(project_id, user_id) DO UPDATE SET role=excluded.role`,
projectID, userID, role)
return err
}
// RemoveMember deletes a membership.
func (db *DB) RemoveMember(projectID, userID string) error {
_, err := db.SQL.Exec(`DELETE FROM project_members WHERE project_id = ? AND user_id = ?`, projectID, userID)
return err
}
// ListMembers lists members of a project.
func (db *DB) ListMembers(projectID string) ([]Member, error) {
rows, err := db.SQL.Query(`
SELECT m.project_id, m.user_id, u.username, m.role
FROM project_members m
JOIN users u ON u.id = m.user_id
WHERE m.project_id = ?
ORDER BY u.username COLLATE NOCASE`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Member
for rows.Next() {
var m Member
if err := rows.Scan(&m.ProjectID, &m.UserID, &m.Username, &m.Role); err != nil {
return nil, err
}
out = append(out, m)
}
if out == nil {
out = []Member{}
}
return out, rows.Err()
}
// MemberRole returns the role for a user on a project, or empty if none.
func (db *DB) MemberRole(projectID, userID string) (string, error) {
var role string
err := db.SQL.QueryRow(`SELECT role FROM project_members WHERE project_id = ? AND user_id = ?`,
projectID, userID).Scan(&role)
if err == sql.ErrNoRows {
return "", nil
}
return role, err
}
// RoleAtLeast reports whether role meets the required minimum.
func RoleAtLeast(role, required string) bool {
rank := map[string]int{
RoleViewer: 1,
RoleMember: 2,
RoleAdmin: 3,
RoleOwner: 4,
}
return rank[role] >= rank[required]
}