18 changed files with 1028 additions and 10 deletions
@ -0,0 +1,122 @@ |
|||||
|
package auth |
||||
|
|
||||
|
import ( |
||||
|
"errors" |
||||
|
"html/template" |
||||
|
"net/http" |
||||
|
"strconv" |
||||
|
"time" |
||||
|
|
||||
|
webtemplates "github.com/domagojzecevic/cammonitor/internal/web/templates" |
||||
|
"github.com/go-chi/chi/v5" |
||||
|
) |
||||
|
|
||||
|
type Handler struct { |
||||
|
store *Store |
||||
|
sessionTTL time.Duration |
||||
|
} |
||||
|
|
||||
|
func NewHandler(store *Store, sessionTTL time.Duration) *Handler { |
||||
|
return &Handler{store: store, sessionTTL: sessionTTL} |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) LoginPage(w http.ResponseWriter, _ *http.Request) { |
||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8") |
||||
|
_ = loginTemplate.ExecuteTemplate(w, webtemplates.Login, nil) |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { |
||||
|
if err := r.ParseForm(); err != nil { |
||||
|
http.Error(w, "invalid form", http.StatusBadRequest) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
user, err := h.store.Authenticate(r.FormValue("username"), r.FormValue("password")) |
||||
|
if errors.Is(err, ErrInvalidCredentials) { |
||||
|
http.Error(w, "invalid credentials", http.StatusUnauthorized) |
||||
|
return |
||||
|
} |
||||
|
if err != nil { |
||||
|
http.Error(w, "login failed", http.StatusInternalServerError) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
token, err := h.store.CreateSession(user.ID, h.sessionTTL) |
||||
|
if err != nil { |
||||
|
http.Error(w, "create session failed", http.StatusInternalServerError) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
http.SetCookie(w, &http.Cookie{ |
||||
|
Name: sessionCookieName, |
||||
|
Value: token, |
||||
|
Path: "/", |
||||
|
Expires: time.Now().UTC().Add(h.sessionTTL), |
||||
|
HttpOnly: true, |
||||
|
SameSite: http.SameSiteLaxMode, |
||||
|
}) |
||||
|
http.Redirect(w, r, "/", http.StatusSeeOther) |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { |
||||
|
if cookie, err := r.Cookie(sessionCookieName); err == nil { |
||||
|
_ = h.store.DeleteSession(cookie.Value) |
||||
|
} |
||||
|
|
||||
|
http.SetCookie(w, &http.Cookie{ |
||||
|
Name: sessionCookieName, |
||||
|
Value: "", |
||||
|
Path: "/", |
||||
|
Expires: time.Unix(0, 0), |
||||
|
MaxAge: -1, |
||||
|
HttpOnly: true, |
||||
|
SameSite: http.SameSiteLaxMode, |
||||
|
}) |
||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther) |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) UsersPage(w http.ResponseWriter, _ *http.Request) { |
||||
|
users, err := h.store.ListUsers() |
||||
|
if err != nil { |
||||
|
http.Error(w, "list users failed", http.StatusInternalServerError) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8") |
||||
|
_ = adminUsersTemplate.ExecuteTemplate(w, webtemplates.AdminUsers, struct { |
||||
|
Users []User |
||||
|
}{Users: users}) |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) { |
||||
|
if err := r.ParseForm(); err != nil { |
||||
|
http.Error(w, "invalid form", http.StatusBadRequest) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
if err := h.store.CreateUser(r.FormValue("username"), r.FormValue("password"), r.FormValue("is_admin") == "on"); err != nil { |
||||
|
http.Error(w, "create user failed", http.StatusBadRequest) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
http.Redirect(w, r, "/admin/users", http.StatusSeeOther) |
||||
|
} |
||||
|
|
||||
|
func (h *Handler) DeleteUser(w http.ResponseWriter, r *http.Request) { |
||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
||||
|
if err != nil { |
||||
|
http.Error(w, "invalid user id", http.StatusBadRequest) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
if err := h.store.DeleteUser(id); err != nil { |
||||
|
http.Error(w, "delete user failed", http.StatusInternalServerError) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
http.Redirect(w, r, "/admin/users", http.StatusSeeOther) |
||||
|
} |
||||
|
|
||||
|
var loginTemplate = template.Must(template.ParseFS(webtemplates.FS, webtemplates.Login)) |
||||
|
|
||||
|
var adminUsersTemplate = template.Must(template.ParseFS(webtemplates.FS, webtemplates.AdminUsers)) |
||||
@ -0,0 +1,192 @@ |
|||||
|
package auth |
||||
|
|
||||
|
import ( |
||||
|
"bytes" |
||||
|
"html/template" |
||||
|
"net/http" |
||||
|
"net/http/httptest" |
||||
|
"net/url" |
||||
|
"os" |
||||
|
"path/filepath" |
||||
|
"strings" |
||||
|
"testing" |
||||
|
"time" |
||||
|
) |
||||
|
|
||||
|
func TestLoginPageRenders(t *testing.T) { |
||||
|
handler, _, _ := newTestHandler(t) |
||||
|
|
||||
|
request := httptest.NewRequest(http.MethodGet, "/login", nil) |
||||
|
response := httptest.NewRecorder() |
||||
|
|
||||
|
handler.LoginPage(response, request) |
||||
|
|
||||
|
if response.Code != http.StatusOK { |
||||
|
t.Fatalf("expected 200, got %d", response.Code) |
||||
|
} |
||||
|
if body := response.Body.String(); !strings.Contains(body, `<form`) || !strings.Contains(body, `name="username"`) { |
||||
|
t.Fatalf("login page missing expected form fields: %s", body) |
||||
|
} |
||||
|
|
||||
|
if got, want := strings.TrimSpace(response.Body.String()), strings.TrimSpace(readTemplateFile(t, "login.html")); got != want { |
||||
|
t.Fatalf("login page was not rendered from template file\nwant:\n%s\n\ngot:\n%s", want, got) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestLoginValidCredentialsSetSessionCookieAndRedirect(t *testing.T) { |
||||
|
handler, store, _ := newTestHandler(t) |
||||
|
if err := store.CreateUser("alice", "secret", false); err != nil { |
||||
|
t.Fatalf("create user: %v", err) |
||||
|
} |
||||
|
|
||||
|
form := url.Values{"username": {"alice"}, "password": {"secret"}} |
||||
|
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) |
||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
||||
|
response := httptest.NewRecorder() |
||||
|
|
||||
|
handler.Login(response, request) |
||||
|
|
||||
|
if response.Code != http.StatusSeeOther { |
||||
|
t.Fatalf("expected 303, got %d", response.Code) |
||||
|
} |
||||
|
if location := response.Header().Get("Location"); location != "/" { |
||||
|
t.Fatalf("expected redirect to /, got %q", location) |
||||
|
} |
||||
|
|
||||
|
cookie := findCookie(response.Result().Cookies(), sessionCookieName) |
||||
|
if cookie == nil || cookie.Value == "" { |
||||
|
t.Fatalf("expected session cookie, got %#v", response.Result().Cookies()) |
||||
|
} |
||||
|
if _, err := store.GetSession(cookie.Value); err != nil { |
||||
|
t.Fatalf("session cookie was not persisted: %v", err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestLoginInvalidCredentialsReturnsUnauthorized(t *testing.T) { |
||||
|
handler, store, _ := newTestHandler(t) |
||||
|
if err := store.CreateUser("alice", "secret", false); err != nil { |
||||
|
t.Fatalf("create user: %v", err) |
||||
|
} |
||||
|
|
||||
|
form := url.Values{"username": {"alice"}, "password": {"wrong"}} |
||||
|
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) |
||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
||||
|
response := httptest.NewRecorder() |
||||
|
|
||||
|
handler.Login(response, request) |
||||
|
|
||||
|
if response.Code != http.StatusUnauthorized { |
||||
|
t.Fatalf("expected 401, got %d", response.Code) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestAdminUsersRequiresAdmin(t *testing.T) { |
||||
|
handler, store, mux := newTestHandler(t) |
||||
|
if err := store.CreateUser("alice", "secret", false); err != nil { |
||||
|
t.Fatalf("create regular user: %v", err) |
||||
|
} |
||||
|
if err := store.CreateUser("admin", "secret", true); err != nil { |
||||
|
t.Fatalf("create admin user: %v", err) |
||||
|
} |
||||
|
|
||||
|
mux.Handle("/admin/users", RequireAuth(store)(RequireAdmin(http.HandlerFunc(handler.UsersPage)))) |
||||
|
|
||||
|
request := httptest.NewRequest(http.MethodGet, "/admin/users", nil) |
||||
|
response := httptest.NewRecorder() |
||||
|
mux.ServeHTTP(response, request) |
||||
|
if response.Code != http.StatusFound { |
||||
|
t.Fatalf("expected unauthenticated redirect, got %d", response.Code) |
||||
|
} |
||||
|
if location := response.Header().Get("Location"); location != "/login" { |
||||
|
t.Fatalf("expected redirect to /login, got %q", location) |
||||
|
} |
||||
|
|
||||
|
regularCookie := sessionCookie(t, store, "alice", "secret") |
||||
|
request = httptest.NewRequest(http.MethodGet, "/admin/users", nil) |
||||
|
request.AddCookie(regularCookie) |
||||
|
response = httptest.NewRecorder() |
||||
|
mux.ServeHTTP(response, request) |
||||
|
if response.Code != http.StatusForbidden { |
||||
|
t.Fatalf("expected regular user forbidden, got %d", response.Code) |
||||
|
} |
||||
|
|
||||
|
adminCookie := sessionCookie(t, store, "admin", "secret") |
||||
|
request = httptest.NewRequest(http.MethodGet, "/admin/users", nil) |
||||
|
request.AddCookie(adminCookie) |
||||
|
response = httptest.NewRecorder() |
||||
|
mux.ServeHTTP(response, request) |
||||
|
if response.Code != http.StatusOK { |
||||
|
t.Fatalf("expected admin page, got %d", response.Code) |
||||
|
} |
||||
|
if !strings.Contains(response.Body.String(), "admin") { |
||||
|
t.Fatalf("admin page missing user list: %s", response.Body.String()) |
||||
|
} |
||||
|
|
||||
|
users, err := store.ListUsers() |
||||
|
if err != nil { |
||||
|
t.Fatalf("list users: %v", err) |
||||
|
} |
||||
|
if got, want := strings.TrimSpace(response.Body.String()), strings.TrimSpace(renderTemplateFile(t, "admin_users.html", struct { |
||||
|
Users []User |
||||
|
}{Users: users})); got != want { |
||||
|
t.Fatalf("admin users page was not rendered from template file\nwant:\n%s\n\ngot:\n%s", want, got) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func newTestHandler(t *testing.T) (*Handler, *Store, *http.ServeMux) { |
||||
|
t.Helper() |
||||
|
|
||||
|
database := openTestDB(t) |
||||
|
store := NewStore(database) |
||||
|
handler := NewHandler(store, time.Hour) |
||||
|
|
||||
|
return handler, store, http.NewServeMux() |
||||
|
} |
||||
|
|
||||
|
func sessionCookie(t *testing.T, store *Store, username, password string) *http.Cookie { |
||||
|
t.Helper() |
||||
|
|
||||
|
user, err := store.Authenticate(username, password) |
||||
|
if err != nil { |
||||
|
t.Fatalf("authenticate %s: %v", username, err) |
||||
|
} |
||||
|
token, err := store.CreateSession(user.ID, time.Hour) |
||||
|
if err != nil { |
||||
|
t.Fatalf("create session: %v", err) |
||||
|
} |
||||
|
return &http.Cookie{Name: sessionCookieName, Value: token} |
||||
|
} |
||||
|
|
||||
|
func findCookie(cookies []*http.Cookie, name string) *http.Cookie { |
||||
|
for _, cookie := range cookies { |
||||
|
if cookie.Name == name { |
||||
|
return cookie |
||||
|
} |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func readTemplateFile(t *testing.T, name string) string { |
||||
|
t.Helper() |
||||
|
|
||||
|
content, err := os.ReadFile(filepath.Join("..", "web", "templates", name)) |
||||
|
if err != nil { |
||||
|
t.Fatalf("read template %s: %v", name, err) |
||||
|
} |
||||
|
return string(content) |
||||
|
} |
||||
|
|
||||
|
func renderTemplateFile(t *testing.T, name string, data any) string { |
||||
|
t.Helper() |
||||
|
|
||||
|
tmpl, err := template.ParseFiles(filepath.Join("..", "web", "templates", name)) |
||||
|
if err != nil { |
||||
|
t.Fatalf("parse template %s: %v", name, err) |
||||
|
} |
||||
|
|
||||
|
var output bytes.Buffer |
||||
|
if err := tmpl.ExecuteTemplate(&output, name, data); err != nil { |
||||
|
t.Fatalf("execute template %s: %v", name, err) |
||||
|
} |
||||
|
return output.String() |
||||
|
} |
||||
@ -0,0 +1,50 @@ |
|||||
|
package auth |
||||
|
|
||||
|
import ( |
||||
|
"context" |
||||
|
"net/http" |
||||
|
) |
||||
|
|
||||
|
const sessionCookieName = "session" |
||||
|
|
||||
|
type contextKey string |
||||
|
|
||||
|
const userContextKey contextKey = "user" |
||||
|
|
||||
|
func RequireAuth(store *Store) func(http.Handler) http.Handler { |
||||
|
return func(next http.Handler) http.Handler { |
||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
||||
|
cookie, err := r.Cookie(sessionCookieName) |
||||
|
if err != nil || cookie.Value == "" { |
||||
|
http.Redirect(w, r, "/login", http.StatusFound) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
session, err := store.GetSession(cookie.Value) |
||||
|
if err != nil { |
||||
|
http.Redirect(w, r, "/login", http.StatusFound) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
ctx := context.WithValue(r.Context(), userContextKey, session.User) |
||||
|
next.ServeHTTP(w, r.WithContext(ctx)) |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func RequireAdmin(next http.Handler) http.Handler { |
||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
||||
|
user, ok := UserFromContext(r.Context()) |
||||
|
if !ok || !user.IsAdmin { |
||||
|
http.Error(w, "forbidden", http.StatusForbidden) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
next.ServeHTTP(w, r) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
func UserFromContext(ctx context.Context) (*User, bool) { |
||||
|
user, ok := ctx.Value(userContextKey).(*User) |
||||
|
return user, ok |
||||
|
} |
||||
@ -0,0 +1,208 @@ |
|||||
|
package auth |
||||
|
|
||||
|
import ( |
||||
|
"crypto/rand" |
||||
|
"database/sql" |
||||
|
"encoding/base64" |
||||
|
"errors" |
||||
|
"fmt" |
||||
|
"strings" |
||||
|
"time" |
||||
|
|
||||
|
"golang.org/x/crypto/bcrypt" |
||||
|
) |
||||
|
|
||||
|
var ErrInvalidCredentials = errors.New("invalid credentials") |
||||
|
var ErrSessionNotFound = errors.New("session not found") |
||||
|
|
||||
|
type Store struct { |
||||
|
db *sql.DB |
||||
|
} |
||||
|
|
||||
|
type User struct { |
||||
|
ID int64 |
||||
|
Username string |
||||
|
IsAdmin bool |
||||
|
} |
||||
|
|
||||
|
type Session struct { |
||||
|
Token string |
||||
|
UserID int64 |
||||
|
ExpiresAt time.Time |
||||
|
User *User |
||||
|
} |
||||
|
|
||||
|
func NewStore(database *sql.DB) *Store { |
||||
|
return &Store{db: database} |
||||
|
} |
||||
|
|
||||
|
func (s *Store) EnsureAdmin(username, password string) error { |
||||
|
username = strings.TrimSpace(username) |
||||
|
if username == "" || password == "" { |
||||
|
return fmt.Errorf("admin username and password are required") |
||||
|
} |
||||
|
|
||||
|
var count int |
||||
|
if err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil { |
||||
|
return fmt.Errorf("count users: %w", err) |
||||
|
} |
||||
|
if count > 0 { |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
return s.CreateUser(username, password, true) |
||||
|
} |
||||
|
|
||||
|
func (s *Store) CreateUser(username, password string, isAdmin bool) error { |
||||
|
username = strings.TrimSpace(username) |
||||
|
if username == "" { |
||||
|
return fmt.Errorf("username is required") |
||||
|
} |
||||
|
if password == "" { |
||||
|
return fmt.Errorf("password is required") |
||||
|
} |
||||
|
|
||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) |
||||
|
if err != nil { |
||||
|
return fmt.Errorf("hash password: %w", err) |
||||
|
} |
||||
|
|
||||
|
adminValue := 0 |
||||
|
if isAdmin { |
||||
|
adminValue = 1 |
||||
|
} |
||||
|
|
||||
|
if _, err := s.db.Exec( |
||||
|
`INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)`, |
||||
|
username, |
||||
|
string(hash), |
||||
|
adminValue, |
||||
|
); err != nil { |
||||
|
return fmt.Errorf("create user: %w", err) |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (s *Store) DeleteUser(id int64) error { |
||||
|
if _, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, id); err != nil { |
||||
|
return fmt.Errorf("delete user: %w", err) |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (s *Store) ListUsers() ([]User, error) { |
||||
|
rows, err := s.db.Query(`SELECT id, username, is_admin FROM users ORDER BY username`) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("list users: %w", err) |
||||
|
} |
||||
|
defer rows.Close() |
||||
|
|
||||
|
var users []User |
||||
|
for rows.Next() { |
||||
|
var user User |
||||
|
var isAdmin int |
||||
|
if err := rows.Scan(&user.ID, &user.Username, &isAdmin); err != nil { |
||||
|
return nil, fmt.Errorf("scan user: %w", err) |
||||
|
} |
||||
|
user.IsAdmin = isAdmin != 0 |
||||
|
users = append(users, user) |
||||
|
} |
||||
|
if err := rows.Err(); err != nil { |
||||
|
return nil, fmt.Errorf("iterate users: %w", err) |
||||
|
} |
||||
|
|
||||
|
return users, nil |
||||
|
} |
||||
|
|
||||
|
func (s *Store) Authenticate(username, password string) (*User, error) { |
||||
|
var user User |
||||
|
var hash string |
||||
|
var isAdmin int |
||||
|
err := s.db.QueryRow( |
||||
|
`SELECT id, username, password_hash, is_admin FROM users WHERE username = ?`, |
||||
|
strings.TrimSpace(username), |
||||
|
).Scan(&user.ID, &user.Username, &hash, &isAdmin) |
||||
|
if errors.Is(err, sql.ErrNoRows) { |
||||
|
return nil, ErrInvalidCredentials |
||||
|
} |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("load user: %w", err) |
||||
|
} |
||||
|
|
||||
|
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil { |
||||
|
return nil, ErrInvalidCredentials |
||||
|
} |
||||
|
|
||||
|
user.IsAdmin = isAdmin != 0 |
||||
|
return &user, nil |
||||
|
} |
||||
|
|
||||
|
func (s *Store) CreateSession(userID int64, ttl time.Duration) (string, error) { |
||||
|
tokenBytes := make([]byte, 32) |
||||
|
if _, err := rand.Read(tokenBytes); err != nil { |
||||
|
return "", fmt.Errorf("generate session token: %w", err) |
||||
|
} |
||||
|
token := base64.RawURLEncoding.EncodeToString(tokenBytes) |
||||
|
expiresAt := time.Now().UTC().Add(ttl) |
||||
|
|
||||
|
if _, err := s.db.Exec( |
||||
|
`INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)`, |
||||
|
token, |
||||
|
userID, |
||||
|
expiresAt.Format(time.RFC3339Nano), |
||||
|
); err != nil { |
||||
|
return "", fmt.Errorf("create session: %w", err) |
||||
|
} |
||||
|
|
||||
|
return token, nil |
||||
|
} |
||||
|
|
||||
|
func (s *Store) GetSession(token string) (*Session, error) { |
||||
|
var session Session |
||||
|
var expiresAt string |
||||
|
var user User |
||||
|
var isAdmin int |
||||
|
err := s.db.QueryRow( |
||||
|
`SELECT s.token, s.user_id, s.expires_at, u.username, u.is_admin |
||||
|
FROM sessions s |
||||
|
JOIN users u ON u.id = s.user_id |
||||
|
WHERE s.token = ?`, |
||||
|
token, |
||||
|
).Scan(&session.Token, &session.UserID, &expiresAt, &user.Username, &isAdmin) |
||||
|
if errors.Is(err, sql.ErrNoRows) { |
||||
|
return nil, ErrSessionNotFound |
||||
|
} |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("load session: %w", err) |
||||
|
} |
||||
|
|
||||
|
parsedExpiresAt, err := time.Parse(time.RFC3339Nano, expiresAt) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("parse session expiry: %w", err) |
||||
|
} |
||||
|
if !parsedExpiresAt.After(time.Now().UTC()) { |
||||
|
_ = s.DeleteSession(token) |
||||
|
return nil, ErrSessionNotFound |
||||
|
} |
||||
|
|
||||
|
user.ID = session.UserID |
||||
|
user.IsAdmin = isAdmin != 0 |
||||
|
session.ExpiresAt = parsedExpiresAt |
||||
|
session.User = &user |
||||
|
return &session, nil |
||||
|
} |
||||
|
|
||||
|
func (s *Store) DeleteSession(token string) error { |
||||
|
if _, err := s.db.Exec(`DELETE FROM sessions WHERE token = ?`, token); err != nil { |
||||
|
return fmt.Errorf("delete session: %w", err) |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (s *Store) PurgeExpiredSessions() error { |
||||
|
if _, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at <= ?`, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { |
||||
|
return fmt.Errorf("purge expired sessions: %w", err) |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
@ -0,0 +1,101 @@ |
|||||
|
package auth |
||||
|
|
||||
|
import ( |
||||
|
"database/sql" |
||||
|
"testing" |
||||
|
"time" |
||||
|
|
||||
|
"github.com/domagojzecevic/cammonitor/internal/db" |
||||
|
) |
||||
|
|
||||
|
func TestStoreAuthenticateHappyPathAndWrongPassword(t *testing.T) { |
||||
|
database := openTestDB(t) |
||||
|
store := NewStore(database) |
||||
|
|
||||
|
if err := store.CreateUser("alice", "secret", false); err != nil { |
||||
|
t.Fatalf("create user: %v", err) |
||||
|
} |
||||
|
|
||||
|
user, err := store.Authenticate("alice", "secret") |
||||
|
if err != nil { |
||||
|
t.Fatalf("authenticate valid user: %v", err) |
||||
|
} |
||||
|
if user.Username != "alice" { |
||||
|
t.Fatalf("expected alice, got %q", user.Username) |
||||
|
} |
||||
|
if user.IsAdmin { |
||||
|
t.Fatal("expected regular user") |
||||
|
} |
||||
|
|
||||
|
if _, err := store.Authenticate("alice", "wrong"); err == nil { |
||||
|
t.Fatal("expected wrong password to fail") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestStoreExpiredSessionIsRejected(t *testing.T) { |
||||
|
database := openTestDB(t) |
||||
|
store := NewStore(database) |
||||
|
|
||||
|
if err := store.CreateUser("alice", "secret", false); err != nil { |
||||
|
t.Fatalf("create user: %v", err) |
||||
|
} |
||||
|
|
||||
|
user, err := store.Authenticate("alice", "secret") |
||||
|
if err != nil { |
||||
|
t.Fatalf("authenticate: %v", err) |
||||
|
} |
||||
|
|
||||
|
token, err := store.CreateSession(user.ID, -time.Minute) |
||||
|
if err != nil { |
||||
|
t.Fatalf("create session: %v", err) |
||||
|
} |
||||
|
|
||||
|
if _, err := store.GetSession(token); err == nil { |
||||
|
t.Fatal("expected expired session to fail") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestEnsureAdminCreatesFirstRunAdminOnlyWhenEmpty(t *testing.T) { |
||||
|
database := openTestDB(t) |
||||
|
store := NewStore(database) |
||||
|
|
||||
|
if err := store.EnsureAdmin("admin", "secret"); err != nil { |
||||
|
t.Fatalf("ensure admin: %v", err) |
||||
|
} |
||||
|
|
||||
|
users, err := store.ListUsers() |
||||
|
if err != nil { |
||||
|
t.Fatalf("list users: %v", err) |
||||
|
} |
||||
|
if len(users) != 1 || users[0].Username != "admin" || !users[0].IsAdmin { |
||||
|
t.Fatalf("unexpected users after bootstrap: %#v", users) |
||||
|
} |
||||
|
|
||||
|
if err := store.EnsureAdmin("other", "secret"); err != nil { |
||||
|
t.Fatalf("ensure admin second run: %v", err) |
||||
|
} |
||||
|
|
||||
|
users, err = store.ListUsers() |
||||
|
if err != nil { |
||||
|
t.Fatalf("list users second run: %v", err) |
||||
|
} |
||||
|
if len(users) != 1 { |
||||
|
t.Fatalf("expected no second bootstrap user, got %d", len(users)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func openTestDB(t *testing.T) *sql.DB { |
||||
|
t.Helper() |
||||
|
|
||||
|
database, err := db.Open(t.TempDir() + "/test.db") |
||||
|
if err != nil { |
||||
|
t.Fatalf("open database: %v", err) |
||||
|
} |
||||
|
t.Cleanup(func() { |
||||
|
if err := database.Close(); err != nil { |
||||
|
t.Fatalf("close database: %v", err) |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
return database |
||||
|
} |
||||
@ -0,0 +1,39 @@ |
|||||
|
<!doctype html> |
||||
|
<html lang="en"> |
||||
|
<head> |
||||
|
<meta charset="utf-8"> |
||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"> |
||||
|
<title>Users - CamMonitor</title> |
||||
|
<script src="https://cdn.tailwindcss.com"></script> |
||||
|
</head> |
||||
|
<body class="min-h-screen bg-slate-950 p-6 text-slate-100"> |
||||
|
<main class="mx-auto max-w-3xl space-y-6"> |
||||
|
<header class="flex items-center justify-between"> |
||||
|
<h1 class="text-2xl font-semibold">Users</h1> |
||||
|
<form method="post" action="/logout"><button class="rounded bg-slate-800 px-3 py-2">Sign out</button></form> |
||||
|
</header> |
||||
|
<form method="post" action="/admin/users" class="grid gap-3 md:grid-cols-[1fr_1fr_auto_auto]"> |
||||
|
<input name="username" placeholder="Username" class="rounded bg-slate-900 px-3 py-2 ring-1 ring-slate-700"> |
||||
|
<input name="password" type="password" placeholder="Password" class="rounded bg-slate-900 px-3 py-2 ring-1 ring-slate-700"> |
||||
|
<label class="flex items-center gap-2 text-sm"><input name="is_admin" type="checkbox"> Admin</label> |
||||
|
<button class="rounded bg-indigo-500 px-3 py-2 font-medium text-white">Add</button> |
||||
|
</form> |
||||
|
<table class="w-full border-collapse text-left"> |
||||
|
<thead><tr class="border-b border-slate-800"><th class="py-2">Username</th><th class="py-2">Role</th><th class="py-2"></th></tr></thead> |
||||
|
<tbody> |
||||
|
{{range .Users}} |
||||
|
<tr class="border-b border-slate-900"> |
||||
|
<td class="py-2">{{.Username}}</td> |
||||
|
<td class="py-2">{{if .IsAdmin}}admin{{else}}user{{end}}</td> |
||||
|
<td class="py-2 text-right"> |
||||
|
<form method="post" action="/admin/users/{{.ID}}/delete"> |
||||
|
<button class="rounded bg-slate-800 px-3 py-1">Delete</button> |
||||
|
</form> |
||||
|
</td> |
||||
|
</tr> |
||||
|
{{end}} |
||||
|
</tbody> |
||||
|
</table> |
||||
|
</main> |
||||
|
</body> |
||||
|
</html> |
||||
@ -0,0 +1,25 @@ |
|||||
|
<!doctype html> |
||||
|
<html lang="en"> |
||||
|
<head> |
||||
|
<meta charset="utf-8"> |
||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"> |
||||
|
<title>Login - CamMonitor</title> |
||||
|
<script src="https://cdn.tailwindcss.com"></script> |
||||
|
</head> |
||||
|
<body class="min-h-screen bg-slate-950 text-slate-100"> |
||||
|
<main class="mx-auto flex min-h-screen max-w-sm items-center px-6"> |
||||
|
<form method="post" action="/login" class="w-full space-y-4"> |
||||
|
<h1 class="text-2xl font-semibold">CamMonitor</h1> |
||||
|
<label class="block space-y-1"> |
||||
|
<span class="text-sm text-slate-300">Username</span> |
||||
|
<input name="username" autocomplete="username" class="w-full rounded bg-slate-900 px-3 py-2 text-slate-100 ring-1 ring-slate-700"> |
||||
|
</label> |
||||
|
<label class="block space-y-1"> |
||||
|
<span class="text-sm text-slate-300">Password</span> |
||||
|
<input name="password" type="password" autocomplete="current-password" class="w-full rounded bg-slate-900 px-3 py-2 text-slate-100 ring-1 ring-slate-700"> |
||||
|
</label> |
||||
|
<button type="submit" class="w-full rounded bg-indigo-500 px-3 py-2 font-medium text-white">Sign in</button> |
||||
|
</form> |
||||
|
</main> |
||||
|
</body> |
||||
|
</html> |
||||
@ -0,0 +1,13 @@ |
|||||
|
package templates |
||||
|
|
||||
|
import "embed" |
||||
|
|
||||
|
const ( |
||||
|
Login = "login.html" |
||||
|
AdminUsers = "admin_users.html" |
||||
|
) |
||||
|
|
||||
|
// FS exposes file-backed HTML templates for handlers that render web pages.
|
||||
|
//
|
||||
|
//go:embed *.html
|
||||
|
var FS embed.FS |
||||
Loading…
Reference in new issue