232 lines
7.1 KiB
Go
232 lines
7.1 KiB
Go
package http
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/emil28092005/SciMesh/users/internal/domain"
|
|
"github.com/emil28092005/SciMesh/users/internal/usecase"
|
|
)
|
|
|
|
// Handlers holds the use cases each endpoint drives.
|
|
type Handlers struct {
|
|
register *usecase.Register
|
|
login *usecase.Login
|
|
setVerified *usecase.SetVerified
|
|
setRole *usecase.SetRole
|
|
createWorkerKey *usecase.CreateWorkerKey
|
|
listWorkerKeys *usecase.ListWorkerKeys
|
|
revokeWorkerKey *usecase.RevokeWorkerKey
|
|
exchangeWorkerKey *usecase.ExchangeWorkerKey
|
|
users usecase.UserRepository
|
|
log *slog.Logger
|
|
}
|
|
|
|
// handleHealth is an unauthenticated liveness probe for the container and load
|
|
// balancer.
|
|
func (h *Handlers) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// handleRegister creates an account. It returns 201 with the public user view,
|
|
// 409 if the email is taken, or 400 on a malformed body / weak password.
|
|
func (h *Handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|
var req registerRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
u, err := h.register.Execute(r.Context(), req.Email, req.Password)
|
|
if err != nil {
|
|
writeError(w, r, h.log, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, toUserResponse(u))
|
|
}
|
|
|
|
// handleLogin verifies credentials and returns a signed token plus the user.
|
|
func (h *Handlers) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
var req loginRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
token, u, err := h.login.Execute(r.Context(), req.Email, req.Password)
|
|
if err != nil {
|
|
writeError(w, r, h.log, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, loginResponse{Token: token, User: toUserResponse(u)})
|
|
}
|
|
|
|
// handleMe returns the caller's own account, proving the token works end to end.
|
|
// It reads the user id the JWT middleware stashed in the context.
|
|
func (h *Handlers) handleMe(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := userIDFrom(r.Context())
|
|
if !ok {
|
|
unauthorized(w, r)
|
|
return
|
|
}
|
|
u, err := h.users.GetByID(r.Context(), id)
|
|
if err != nil {
|
|
writeError(w, r, h.log, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, toUserResponse(u))
|
|
}
|
|
|
|
// handleSetVerified grants (verified=true) or revokes (false) the trusted-
|
|
// contributor badge for the user in the path. Admin-only; the withAdmin
|
|
// middleware has already enforced the role by the time this runs.
|
|
func (h *Handlers) handleSetVerified(verified bool) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "invalid user id",
|
|
RequestID: requestIDFrom(r.Context()),
|
|
})
|
|
return
|
|
}
|
|
if err := h.setVerified.Execute(r.Context(), id, verified); err != nil {
|
|
writeError(w, r, h.log, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// handleSetRole promotes (admin) or demotes (user) the user in the path. Admin-
|
|
// only; the withAdmin middleware has already enforced the caller's role.
|
|
func (h *Handlers) handleSetRole(role domain.Role) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "invalid user id",
|
|
RequestID: requestIDFrom(r.Context()),
|
|
})
|
|
return
|
|
}
|
|
if err := h.setRole.Execute(r.Context(), id, role); err != nil {
|
|
writeError(w, r, h.log, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// handleCreateWorkerKey mints a long-lived worker key for the authenticated
|
|
// caller and returns it once, plaintext included. The user copies it into their
|
|
// worker's SCIMESH_WORKER_KEY; it is never retrievable again.
|
|
func (h *Handlers) handleCreateWorkerKey(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := userIDFrom(r.Context())
|
|
if !ok {
|
|
unauthorized(w, r)
|
|
return
|
|
}
|
|
// A signed JWT can outlive a demo reset or an account deletion. Check that
|
|
// its subject still exists before attempting the insert, otherwise the
|
|
// worker_keys foreign key would turn a stale session into an internal error.
|
|
if _, err := h.users.GetByID(r.Context(), id); err != nil {
|
|
if errors.Is(err, usecase.ErrUserNotFound) {
|
|
unauthorized(w, r)
|
|
return
|
|
}
|
|
writeError(w, r, h.log, err)
|
|
return
|
|
}
|
|
var req createWorkerKeyRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
key, raw, err := h.createWorkerKey.Execute(r.Context(), id, req.Name)
|
|
if err != nil {
|
|
writeError(w, r, h.log, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, createdWorkerKeyResponse{
|
|
workerKeyResponse: toWorkerKeyResponse(key),
|
|
Key: raw,
|
|
})
|
|
}
|
|
|
|
// handleListWorkerKeys returns the caller's live keys (no secrets) for display
|
|
// and revocation.
|
|
func (h *Handlers) handleListWorkerKeys(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := userIDFrom(r.Context())
|
|
if !ok {
|
|
unauthorized(w, r)
|
|
return
|
|
}
|
|
keys, err := h.listWorkerKeys.Execute(r.Context(), id)
|
|
if err != nil {
|
|
writeError(w, r, h.log, err)
|
|
return
|
|
}
|
|
out := make([]workerKeyResponse, 0, len(keys))
|
|
for _, k := range keys {
|
|
out = append(out, toWorkerKeyResponse(k))
|
|
}
|
|
writeJSON(w, http.StatusOK, workerKeysResponse{WorkerKeys: out})
|
|
}
|
|
|
|
// handleRevokeWorkerKey retires one of the caller's keys. The repository scopes
|
|
// the delete to the owner, so a mismatched id is a clean 404, not another user's
|
|
// key.
|
|
func (h *Handlers) handleRevokeWorkerKey(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := userIDFrom(r.Context())
|
|
if !ok {
|
|
unauthorized(w, r)
|
|
return
|
|
}
|
|
keyID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "invalid worker key id",
|
|
RequestID: requestIDFrom(r.Context()),
|
|
})
|
|
return
|
|
}
|
|
if err := h.revokeWorkerKey.Execute(r.Context(), userID, keyID); err != nil {
|
|
writeError(w, r, h.log, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// handleExchangeWorkerKey trades a worker key for a short-lived JWT. It is
|
|
// unauthenticated: the key itself is the credential. A worker calls this on
|
|
// startup and again to refresh before the JWT expires.
|
|
func (h *Handlers) handleExchangeWorkerKey(w http.ResponseWriter, r *http.Request) {
|
|
var req exchangeWorkerKeyRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
token, expiresIn, err := h.exchangeWorkerKey.Execute(r.Context(), req.Key)
|
|
if err != nil {
|
|
writeError(w, r, h.log, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, exchangeWorkerKeyResponse{Token: token, ExpiresIn: expiresIn})
|
|
}
|
|
|
|
// decodeJSON reads a size-capped JSON body into dst, rejecting unknown fields.
|
|
// It writes a 400 and returns false on any problem, so callers can `if
|
|
// !decodeJSON(...) { return }`.
|
|
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody)
|
|
dec := json.NewDecoder(r.Body)
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(dst); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
Error: "invalid JSON body",
|
|
RequestID: requestIDFrom(r.Context()),
|
|
})
|
|
return false
|
|
}
|
|
return true
|
|
}
|