[ci skip] Move Terraform modules into stack directories
Move all 88 service modules (66 individual + 22 platform) from modules/kubernetes/<service>/ into their corresponding stack directories: - Service stacks: stacks/<service>/module/ - Platform stack: stacks/platform/modules/<service>/ This collocates module source code with its Terragrunt definition. Only shared utility modules remain in modules/kubernetes/: ingress_factory, setup_tls_secret, dockerhub_secret, oauth-proxy. All cross-references to shared modules updated to use correct relative paths. Verified with terragrunt run --all -- plan: 0 adds, 0 destroys across all 68 stacks.
This commit is contained in:
parent
73cb696f12
commit
e225e81ebf
614 changed files with 12075 additions and 352 deletions
359
stacks/f1-stream/module/files/internal/auth/auth.go
Normal file
359
stacks/f1-stream/module/files/internal/auth/auth.go
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"f1-stream/internal/models"
|
||||
"f1-stream/internal/store"
|
||||
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_]{3,30}$`)
|
||||
|
||||
type Auth struct {
|
||||
store *store.Store
|
||||
webauthn *webauthn.WebAuthn
|
||||
adminUsername string
|
||||
sessionTTL time.Duration
|
||||
|
||||
// In-memory storage for WebAuthn ceremony session data (short-lived)
|
||||
regSessions map[string]*webauthn.SessionData
|
||||
loginSessions map[string]*webauthn.SessionData
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func New(s *store.Store, rpDisplayName, rpID string, rpOrigins []string, adminUsername string, sessionTTL time.Duration) (*Auth, error) {
|
||||
wconfig := &webauthn.Config{
|
||||
RPDisplayName: rpDisplayName,
|
||||
RPID: rpID,
|
||||
RPOrigins: rpOrigins,
|
||||
}
|
||||
w, err := webauthn.New(wconfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("webauthn init: %w", err)
|
||||
}
|
||||
return &Auth{
|
||||
store: s,
|
||||
webauthn: w,
|
||||
adminUsername: adminUsername,
|
||||
sessionTTL: sessionTTL,
|
||||
regSessions: make(map[string]*webauthn.SessionData),
|
||||
loginSessions: make(map[string]*webauthn.SessionData),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BeginRegistration starts the WebAuthn registration ceremony.
|
||||
func (a *Auth) BeginRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !usernameRe.MatchString(req.Username) {
|
||||
http.Error(w, `{"error":"username must be 3-30 chars, alphanumeric or underscore"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := a.store.GetUserByName(req.Username)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if existing != nil {
|
||||
http.Error(w, `{"error":"username already taken"}`, http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := randomID()
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
isAdmin := false
|
||||
if a.adminUsername != "" && req.Username == a.adminUsername {
|
||||
isAdmin = true
|
||||
} else if a.adminUsername == "" {
|
||||
count, err := a.store.UserCount()
|
||||
if err == nil && count == 0 {
|
||||
isAdmin = true
|
||||
}
|
||||
}
|
||||
|
||||
user := &models.User{
|
||||
ID: id,
|
||||
Username: req.Username,
|
||||
IsAdmin: isAdmin,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
options, session, err := a.webauthn.BeginRegistration(user)
|
||||
if err != nil {
|
||||
log.Printf("BeginRegistration error: %v", err)
|
||||
http.Error(w, `{"error":"failed to begin registration"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.regSessions[req.Username] = session
|
||||
a.mu.Unlock()
|
||||
|
||||
// Clean up session after 5 minutes
|
||||
go func() {
|
||||
time.Sleep(5 * time.Minute)
|
||||
a.mu.Lock()
|
||||
delete(a.regSessions, req.Username)
|
||||
a.mu.Unlock()
|
||||
}()
|
||||
|
||||
// Store user temporarily - will be committed on finish
|
||||
// We create the user now so FinishRegistration can look it up
|
||||
if err := a.store.CreateUser(*user); err != nil {
|
||||
http.Error(w, `{"error":"failed to create user"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(options)
|
||||
}
|
||||
|
||||
// FinishRegistration completes the WebAuthn registration ceremony.
|
||||
func (a *Auth) FinishRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
// Username is passed as query param since body is the attestation response
|
||||
username := r.URL.Query().Get("username")
|
||||
if username == "" {
|
||||
// Try to decode from a wrapper
|
||||
http.Error(w, `{"error":"username required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.Username = username
|
||||
|
||||
a.mu.Lock()
|
||||
session, ok := a.regSessions[req.Username]
|
||||
if ok {
|
||||
delete(a.regSessions, req.Username)
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"no registration in progress"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := a.store.GetUserByName(req.Username)
|
||||
if err != nil || user == nil {
|
||||
http.Error(w, `{"error":"user not found"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
credential, err := a.webauthn.FinishRegistration(user, *session, r)
|
||||
if err != nil {
|
||||
log.Printf("FinishRegistration error: %v", err)
|
||||
http.Error(w, `{"error":"registration failed"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user.Credentials = append(user.Credentials, *credential)
|
||||
if err := a.store.UpdateUserCredentials(user.ID, user.Credentials); err != nil {
|
||||
http.Error(w, `{"error":"failed to save credential"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Create session
|
||||
token, err := a.store.CreateSession(user.ID, a.sessionTTL)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to create session"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Secure: r.TLS != nil,
|
||||
MaxAge: int(a.sessionTTL.Seconds()),
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"is_admin": user.IsAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
// BeginLogin starts the WebAuthn login ceremony.
|
||||
func (a *Auth) BeginLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := a.store.GetUserByName(req.Username)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
http.Error(w, `{"error":"user not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if len(user.Credentials) == 0 {
|
||||
http.Error(w, `{"error":"no credentials registered"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
options, session, err := a.webauthn.BeginLogin(user)
|
||||
if err != nil {
|
||||
log.Printf("BeginLogin error: %v", err)
|
||||
http.Error(w, `{"error":"failed to begin login"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.loginSessions[req.Username] = session
|
||||
a.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
time.Sleep(5 * time.Minute)
|
||||
a.mu.Lock()
|
||||
delete(a.loginSessions, req.Username)
|
||||
a.mu.Unlock()
|
||||
}()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(options)
|
||||
}
|
||||
|
||||
// FinishLogin completes the WebAuthn login ceremony.
|
||||
func (a *Auth) FinishLogin(w http.ResponseWriter, r *http.Request) {
|
||||
username := r.URL.Query().Get("username")
|
||||
if username == "" {
|
||||
http.Error(w, `{"error":"username required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
session, ok := a.loginSessions[username]
|
||||
if ok {
|
||||
delete(a.loginSessions, username)
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"no login in progress"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := a.store.GetUserByName(username)
|
||||
if err != nil || user == nil {
|
||||
http.Error(w, `{"error":"user not found"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
credential, err := a.webauthn.FinishLogin(user, *session, r)
|
||||
if err != nil {
|
||||
log.Printf("FinishLogin error: %v", err)
|
||||
http.Error(w, `{"error":"login failed"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Update credential sign count
|
||||
for i, c := range user.Credentials {
|
||||
if string(c.ID) == string(credential.ID) {
|
||||
user.Credentials[i].Authenticator.SignCount = credential.Authenticator.SignCount
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := a.store.UpdateUserCredentials(user.ID, user.Credentials); err != nil {
|
||||
log.Printf("Failed to update credential sign count: %v", err)
|
||||
}
|
||||
|
||||
token, err := a.store.CreateSession(user.ID, a.sessionTTL)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to create session"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Secure: r.TLS != nil,
|
||||
MaxAge: int(a.sessionTTL.Seconds()),
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"is_admin": user.IsAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
// Logout clears the session.
|
||||
func (a *Auth) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("session")
|
||||
if err == nil {
|
||||
a.store.DeleteSession(cookie.Value)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
MaxAge: -1,
|
||||
})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
}
|
||||
|
||||
// Me returns the current user info.
|
||||
func (a *Auth) Me(w http.ResponseWriter, r *http.Request) {
|
||||
user := UserFromContext(r.Context())
|
||||
if user == nil {
|
||||
http.Error(w, `{"error":"not authenticated"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"is_admin": user.IsAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
// GetSessionUser returns the user for a session token.
|
||||
func (a *Auth) GetSessionUser(token string) (*models.User, error) {
|
||||
sess, err := a.store.GetSession(token)
|
||||
if err != nil || sess == nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.store.GetUserByID(sess.UserID)
|
||||
}
|
||||
|
||||
func randomID() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%x", b), nil
|
||||
}
|
||||
20
stacks/f1-stream/module/files/internal/auth/context.go
Normal file
20
stacks/f1-stream/module/files/internal/auth/context.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"f1-stream/internal/models"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const userKey contextKey = "user"
|
||||
|
||||
func ContextWithUser(ctx context.Context, user *models.User) context.Context {
|
||||
return context.WithValue(ctx, userKey, user)
|
||||
}
|
||||
|
||||
func UserFromContext(ctx context.Context) *models.User {
|
||||
user, _ := ctx.Value(userKey).(*models.User)
|
||||
return user
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue