Stage 2: users, API key auth, bootstrap endpoint
- Migration 002: users and api_keys tables (Unix timestamps, FK cascade)
- POST /api/bootstrap — creates first user + key when DB is empty
- POST/GET/DELETE /api/users — user CRUD
- POST/DELETE /api/users/{id}/api-keys — key issuance and revocation
- AuthMiddleware: SHA-256 bearer token lookup, last_used_at tracking
- Raw key returned once on creation; only SHA-256 hash stored
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func respond(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(r *http.Request, v any) error {
|
||||||
|
defer r.Body.Close()
|
||||||
|
return json.NewDecoder(r.Body).Decode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func errResp(msg string) map[string]string {
|
||||||
|
return map[string]string{"error": msg}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yeniklas/terdut-server/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const ctxUser contextKey = "user"
|
||||||
|
|
||||||
|
func AuthMiddleware(db *sql.DB) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||||
|
if !ok || token == "" {
|
||||||
|
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h := sha256.Sum256([]byte(token))
|
||||||
|
hash := hex.EncodeToString(h[:])
|
||||||
|
|
||||||
|
var keyID, userID int64
|
||||||
|
err := db.QueryRowContext(r.Context(),
|
||||||
|
"SELECT id, user_id FROM api_keys WHERE key_hash = ?", hash,
|
||||||
|
).Scan(&keyID, &userID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// best-effort; don't fail the request if this update fails
|
||||||
|
db.ExecContext(r.Context(),
|
||||||
|
"UPDATE api_keys SET last_used_at = ? WHERE id = ?",
|
||||||
|
time.Now().Unix(), keyID)
|
||||||
|
|
||||||
|
var u models.User
|
||||||
|
var createdUnix int64
|
||||||
|
if err := db.QueryRowContext(r.Context(),
|
||||||
|
"SELECT id, username, email, created_at FROM users WHERE id = ?", userID,
|
||||||
|
).Scan(&u.ID, &u.Username, &u.Email, &createdUnix); err != nil {
|
||||||
|
respond(w, http.StatusUnauthorized, errResp("unauthorized"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.CreatedAt = time.Unix(createdUnix, 0).UTC()
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxUser, u)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func userFromContext(ctx context.Context) (models.User, bool) {
|
||||||
|
u, ok := ctx.Value(ctxUser).(models.User)
|
||||||
|
return u, ok
|
||||||
|
}
|
||||||
+15
-3
@@ -2,7 +2,6 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -15,8 +14,21 @@ func NewRouter(db *sql.DB) http.Handler {
|
|||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
|
|
||||||
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
respond(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
})
|
||||||
|
|
||||||
|
// Unauthenticated: bootstrap (only works when user table is empty).
|
||||||
|
r.Post("/api/bootstrap", handleBootstrap(db))
|
||||||
|
|
||||||
|
// All other /api routes require a valid API key.
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(AuthMiddleware(db))
|
||||||
|
|
||||||
|
r.Get("/api/users", handleListUsers(db))
|
||||||
|
r.Post("/api/users", handleCreateUser(db))
|
||||||
|
r.Delete("/api/users/{id}", handleDeleteUser(db))
|
||||||
|
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
|
||||||
|
r.Delete("/api/users/{id}/api-keys/{keyID}", handleDeleteAPIKey(db))
|
||||||
})
|
})
|
||||||
|
|
||||||
return r
|
return r
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/yeniklas/terdut-server/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleBootstrap(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Username == "" || req.Email == "" {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("username and email are required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int
|
||||||
|
if err := db.QueryRowContext(r.Context(), "SELECT COUNT(*) FROM users").Scan(&count); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
respond(w, http.StatusForbidden, errResp("bootstrap already completed"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := db.ExecContext(r.Context(),
|
||||||
|
"INSERT INTO users (username, email) VALUES (?, ?)", req.Username, req.Email)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, _ := res.LastInsertId()
|
||||||
|
|
||||||
|
raw, hash, err := newAPIKey()
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keyRes, err := db.ExecContext(r.Context(),
|
||||||
|
"INSERT INTO api_keys (user_id, key_hash, name) VALUES (?, ?, ?)", userID, hash, "bootstrap")
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keyID, _ := keyRes.LastInsertId()
|
||||||
|
|
||||||
|
user, _ := fetchUser(r.Context(), db, userID)
|
||||||
|
key := models.APIKey{ID: keyID, UserID: userID, Name: "bootstrap", Key: raw, CreatedAt: user.CreatedAt}
|
||||||
|
respond(w, http.StatusCreated, map[string]any{"user": user, "api_key": key})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleListUsers(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := db.QueryContext(r.Context(),
|
||||||
|
"SELECT id, username, email, created_at FROM users ORDER BY id")
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
users := []models.User{}
|
||||||
|
for rows.Next() {
|
||||||
|
var u models.User
|
||||||
|
var ts int64
|
||||||
|
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.CreatedAt = time.Unix(ts, 0).UTC()
|
||||||
|
users = append(users, u)
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, users)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCreateUser(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Username == "" || req.Email == "" {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("username and email are required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := db.ExecContext(r.Context(),
|
||||||
|
"INSERT INTO users (username, email) VALUES (?, ?)", req.Username, req.Email)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||||
|
respond(w, http.StatusConflict, errResp("username or email already exists"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
user, _ := fetchUser(r.Context(), db, id)
|
||||||
|
respond(w, http.StatusCreated, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDeleteUser(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := db.ExecContext(r.Context(), "DELETE FROM users WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
respond(w, http.StatusNotFound, errResp("user not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Name == "" {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("name is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var exists int
|
||||||
|
if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM users WHERE id = ?", userID).Scan(&exists); err != nil {
|
||||||
|
respond(w, http.StatusNotFound, errResp("user not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, hash, err := newAPIKey()
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := db.ExecContext(r.Context(),
|
||||||
|
"INSERT INTO api_keys (user_id, key_hash, name) VALUES (?, ?, ?)", userID, hash, req.Name)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keyID, _ := res.LastInsertId()
|
||||||
|
key := models.APIKey{ID: keyID, UserID: userID, Name: req.Name, Key: raw, CreatedAt: time.Now().UTC()}
|
||||||
|
respond(w, http.StatusCreated, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDeleteAPIKey(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keyID, err := strconv.ParseInt(chi.URLParam(r, "keyID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusBadRequest, errResp("invalid key id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := db.ExecContext(r.Context(),
|
||||||
|
"DELETE FROM api_keys WHERE id = ? AND user_id = ?", keyID, userID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
respond(w, http.StatusNotFound, errResp("api key not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAPIKey generates a random 32-byte key encoded as hex, plus its SHA-256 hash for storage.
|
||||||
|
func newAPIKey() (raw, hash string, err error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err = rand.Read(b); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw = hex.EncodeToString(b)
|
||||||
|
h := sha256.Sum256([]byte(raw))
|
||||||
|
hash = hex.EncodeToString(h[:])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
|
||||||
|
var u models.User
|
||||||
|
var ts int64
|
||||||
|
err := db.QueryRowContext(ctx, "SELECT id, username, email, created_at FROM users WHERE id = ?", id).
|
||||||
|
Scan(&u.ID, &u.Username, &u.Email, &ts)
|
||||||
|
if err != nil {
|
||||||
|
return u, err
|
||||||
|
}
|
||||||
|
u.CreatedAt = time.Unix(ts, 0).UTC()
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE api_keys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
key_hash TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||||
|
last_used_at INTEGER
|
||||||
|
);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIKey struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||||
|
Key string `json:"key,omitempty"` // populated only on creation, never stored
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user