IAM-08: benutzerprofil-login-oberflaeche (login+2fa/passwort-reset/profil-backend-handler + web/account next.js-frontend auf shl-01)
This commit is contained in:
@@ -0,0 +1,65 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChangePasswordHandler stellt die Passwortaenderung fuer den bereits
|
||||||
|
// angemeldeten Benutzer bereit (IAM-08 Akzeptanzkriterium 2). Getrennt von
|
||||||
|
// Handler (Login/Logout), weil hier zusaetzlich der TenantUserStore fuer das
|
||||||
|
// Aktualisieren des Hashes gebraucht wird.
|
||||||
|
type ChangePasswordHandler struct {
|
||||||
|
users *user.TenantUserStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChangePasswordHandler(users *user.TenantUserStore) *ChangePasswordHandler {
|
||||||
|
return &ChangePasswordHandler{users: users}
|
||||||
|
}
|
||||||
|
|
||||||
|
type changePasswordRequest struct {
|
||||||
|
CurrentPassword string `json:"current_password"`
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword verlangt das aktuelle Passwort, bevor ein neues gesetzt wird
|
||||||
|
// — ohne diese Pruefung koennte ein gekapertes, noch gueltiges Session-Cookie
|
||||||
|
// allein zur vollstaendigen Kontoübernahme reichen (Passwort setzen, alte
|
||||||
|
// Session unbrauchbar machen). Muss hinter RequireAuth haengen.
|
||||||
|
func (h *ChangePasswordHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := ClaimsFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req changePasswordRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
creds, err := h.users.GetByIDForAuth(r.Context(), claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Aktuelles Passwort oder neues Passwort ungültig.", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !VerifyPassword(creds.PasswordHash, req.CurrentPassword) {
|
||||||
|
http.Error(w, "Aktuelles Passwort oder neues Passwort ungültig.", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newHash, err := HashPassword(req.NewPassword)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Aktuelles Passwort oder neues Passwort ungültig.", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.users.SetPasswordHash(r.Context(), claims.UserID, newHash); err != nil {
|
||||||
|
http.Error(w, "Passwort konnte nicht gespeichert werden.", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2: Passwortaenderung verlangt das aktuelle Passwort.
|
||||||
|
func TestChangePassword_RequiresCurrentPassword(t *testing.T) {
|
||||||
|
pool := setupTenantDB(t, "changepw_test_wrong")
|
||||||
|
store := user.NewTenantUserStore(pool)
|
||||||
|
u := createUserWithPassword(t, store, "nutzer@example.com", "altes-passwort")
|
||||||
|
|
||||||
|
issuer := NewTokenIssuer("test-session-secret")
|
||||||
|
sessionToken, err := issuer.Issue(u.ID, "acme")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("session-token ausstellen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewChangePasswordHandler(store)
|
||||||
|
body, _ := json.Marshal(map[string]string{"current_password": "falsches-altes-passwort", "new_password": "neues-passwort"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/account/change-password", bytes.NewReader(body))
|
||||||
|
req.AddCookie(&http.Cookie{Name: CookieName, Value: sessionToken})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
RequireAuth(issuer, h.ChangePassword)(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400 (falsches aktuelles passwort)", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
creds, err := store.GetByIDForAuth(req.Context(), u.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("credentials laden: %v", err)
|
||||||
|
}
|
||||||
|
if !VerifyPassword(creds.PasswordHash, "altes-passwort") {
|
||||||
|
t.Fatal("passwort haette NICHT geaendert werden duerfen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChangePassword_SucceedsWithCorrectCurrentPassword(t *testing.T) {
|
||||||
|
pool := setupTenantDB(t, "changepw_test_ok")
|
||||||
|
store := user.NewTenantUserStore(pool)
|
||||||
|
u := createUserWithPassword(t, store, "nutzer2@example.com", "altes-passwort")
|
||||||
|
|
||||||
|
issuer := NewTokenIssuer("test-session-secret")
|
||||||
|
sessionToken, err := issuer.Issue(u.ID, "acme")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("session-token ausstellen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewChangePasswordHandler(store)
|
||||||
|
body, _ := json.Marshal(map[string]string{"current_password": "altes-passwort", "new_password": "neues-sicheres-passwort"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/account/change-password", bytes.NewReader(body))
|
||||||
|
req.AddCookie(&http.Cookie{Name: CookieName, Value: sessionToken})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
RequireAuth(issuer, h.ChangePassword)(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
creds, err := store.GetByIDForAuth(req.Context(), u.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("credentials laden: %v", err)
|
||||||
|
}
|
||||||
|
if !VerifyPassword(creds.PasswordHash, "neues-sicheres-passwort") {
|
||||||
|
t.Fatal("neues passwort wurde nicht uebernommen")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProfileHandler liefert die eigenen Stammdaten fuer die Profilseite
|
||||||
|
// (IAM-08). Getrennt von ChangePasswordHandler, da unterschiedliche
|
||||||
|
// Lesbarkeitsanforderungen (nur GET, kein Nebeneffekt).
|
||||||
|
type ProfileHandler struct {
|
||||||
|
users *user.TenantUserStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProfileHandler(users *user.TenantUserStore) *ProfileHandler {
|
||||||
|
return &ProfileHandler{users: users}
|
||||||
|
}
|
||||||
|
|
||||||
|
type meResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Me liefert die Stammdaten des angemeldeten Benutzers. Muss hinter
|
||||||
|
// RequireAuth haengen.
|
||||||
|
func (h *ProfileHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := ClaimsFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := h.users.Get(r.Context(), claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "benutzer nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(meResponse{ID: u.ID, Email: u.Email, Name: u.Name})
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProfileMe_ReturnsOwnData(t *testing.T) {
|
||||||
|
pool := setupTenantDB(t, "profile_test_me")
|
||||||
|
store := user.NewTenantUserStore(pool)
|
||||||
|
u := createUserWithPassword(t, store, "profil@example.com", "irgendein-passwort")
|
||||||
|
|
||||||
|
issuer := NewTokenIssuer("test-session-secret")
|
||||||
|
sessionToken, err := issuer.Issue(u.ID, "acme")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("session-token ausstellen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewProfileHandler(store)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/account/me", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: CookieName, Value: sessionToken})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
RequireAuth(issuer, h.Me)(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfileMe_RejectsWithoutSession(t *testing.T) {
|
||||||
|
pool := setupTenantDB(t, "profile_test_noauth")
|
||||||
|
store := user.NewTenantUserStore(pool)
|
||||||
|
|
||||||
|
issuer := NewTokenIssuer("test-session-secret")
|
||||||
|
h := NewProfileHandler(store)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/account/me", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
RequireAuth(issuer, h.Me)(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status = %d, want 401 ohne session-cookie", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package authtoken
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResetTokenTTL: 1 Stunde ist der uebliche Kompromiss zwischen Nutzbarkeit
|
||||||
|
// und Angriffsflaeche fuer Passwort-Reset-Links.
|
||||||
|
const ResetTokenTTL = time.Hour
|
||||||
|
|
||||||
|
// Notifier verschickt das Reset-Token an den Benutzer. Core CFG-02
|
||||||
|
// (Benachrichtigungs-Dispatcher) ist noch nicht in diesem Branch verfuegbar
|
||||||
|
// (siehe SICHERHEITSKONZEPT.md / Core-Board-Konvention: Versand laeuft
|
||||||
|
// grundsaetzlich ueber CFG-02, kein eigener E-Mail-Versand je Modul) — bis
|
||||||
|
// zur Anbindung protokolliert LogNotifier den Vorgang nur, statt eine E-Mail
|
||||||
|
// zu verschicken. Produktionsreif ist erst der Austausch gegen einen
|
||||||
|
// CFG-02-Client, keine neue eigene Versandlogik hier.
|
||||||
|
type Notifier interface {
|
||||||
|
NotifyPasswordReset(ctx context.Context, email, token string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogNotifier struct{}
|
||||||
|
|
||||||
|
func (LogNotifier) NotifyPasswordReset(_ context.Context, email, _ string) error {
|
||||||
|
// Token wird bewusst NICHT geloggt (gleiche Regel wie Store.Create).
|
||||||
|
slog.Info("passwort-reset angefordert (kein CFG-02 in diesem branch, nur protokolliert)", "email_domain_hint", domainHint(email))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func domainHint(email string) string {
|
||||||
|
for i := len(email) - 1; i >= 0; i-- {
|
||||||
|
if email[i] == '@' {
|
||||||
|
return email[i:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler stellt Passwort-Reset als HTTP-Endpunkte bereit (IAM-08).
|
||||||
|
type Handler struct {
|
||||||
|
tokens *Store
|
||||||
|
users *user.TenantUserStore
|
||||||
|
notifier Notifier
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(tokens *Store, users *user.TenantUserStore, notifier Notifier) *Handler {
|
||||||
|
if notifier == nil {
|
||||||
|
notifier = LogNotifier{}
|
||||||
|
}
|
||||||
|
return &Handler{tokens: tokens, users: users, notifier: notifier}
|
||||||
|
}
|
||||||
|
|
||||||
|
type requestResetRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestReset liefert IMMER dieselbe Erfolgsmeldung, unabhaengig davon, ob
|
||||||
|
// die E-Mail-Adresse existiert (Akzeptanzkriterium 3 / Pruefung 1,
|
||||||
|
// User-Enumeration-Schutz — gleiches Prinzip wie auth.LoginService.Login).
|
||||||
|
func (h *Handler) RequestReset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req requestResetRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absichtlich: Fehler (unbekannte E-Mail) fuehrt NICHT zu einer anderen
|
||||||
|
// HTTP-Antwort als der Erfolgsfall — nur der interne Pfad unterscheidet sich.
|
||||||
|
creds, err := h.users.GetByEmailForAuth(r.Context(), req.Email)
|
||||||
|
if err == nil {
|
||||||
|
token, tokenErr := h.tokens.Create(r.Context(), creds.User.ID, PurposeReset, ResetTokenTTL)
|
||||||
|
if tokenErr == nil {
|
||||||
|
_ = h.notifier.NotifyPasswordReset(r.Context(), req.Email, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"message": "Falls ein Konto mit dieser E-Mail-Adresse existiert, wurde eine Nachricht mit weiteren Schritten verschickt.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type completeResetRequest struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteReset schliesst den Reset ab (Akzeptanzkriterium 3: generische
|
||||||
|
// Fehlermeldung bei ungueltigem/abgelaufenem Token, siehe ErrInvalidToken).
|
||||||
|
func (h *Handler) CompleteReset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req completeResetRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := CompletePasswordReset(r.Context(), h.tokens, h.users, req.Token, req.NewPassword); err != nil {
|
||||||
|
http.Error(w, "Link ungültig oder abgelaufen.", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package authtoken
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeNotifier zeichnet auf, ob/mit welchem Token benachrichtigt wurde —
|
||||||
|
// ersetzt LogNotifier im Test, damit der Test pruefen kann, OB ein Token
|
||||||
|
// erzeugt wurde, ohne die HTTP-Antwort danach zu unterscheiden
|
||||||
|
// (Akzeptanzkriterium 3: die Antwort selbst bleibt in beiden Faellen gleich).
|
||||||
|
type fakeNotifier struct {
|
||||||
|
calls []string // e-mail-adressen, fuer die notifiziert wurde
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeNotifier) NotifyPasswordReset(_ context.Context, email, _ string) error {
|
||||||
|
f.calls = append(f.calls, email)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 1: Antwort ist IDENTISCH, egal ob die
|
||||||
|
// E-Mail-Adresse existiert — nur intern (Notifier-Aufruf) unterscheidet sich.
|
||||||
|
func TestRequestReset_SameResponseRegardlessOfExistence(t *testing.T) {
|
||||||
|
tokens, users, userID, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
_ = userID
|
||||||
|
|
||||||
|
u, err := users.Get(context.Background(), userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("benutzer laden: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
notifier := &fakeNotifier{}
|
||||||
|
h := NewHandler(tokens, users, notifier)
|
||||||
|
|
||||||
|
// Fall 1: existierende E-Mail.
|
||||||
|
body1, _ := json.Marshal(requestResetRequest{Email: u.Email})
|
||||||
|
req1 := httptest.NewRequest(http.MethodPost, "/auth/password-reset/request", bytes.NewReader(body1))
|
||||||
|
rec1 := httptest.NewRecorder()
|
||||||
|
h.RequestReset(rec1, req1)
|
||||||
|
|
||||||
|
// Fall 2: nicht existierende E-Mail.
|
||||||
|
body2, _ := json.Marshal(requestResetRequest{Email: "gibt-es-nicht@example.com"})
|
||||||
|
req2 := httptest.NewRequest(http.MethodPost, "/auth/password-reset/request", bytes.NewReader(body2))
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
h.RequestReset(rec2, req2)
|
||||||
|
|
||||||
|
if rec1.Code != rec2.Code {
|
||||||
|
t.Fatalf("status codes unterscheiden sich: %d vs %d — verraet Konto-Existenz", rec1.Code, rec2.Code)
|
||||||
|
}
|
||||||
|
if rec1.Body.String() != rec2.Body.String() {
|
||||||
|
t.Fatalf("antwort-body unterscheidet sich: %q vs %q — verraet Konto-Existenz", rec1.Body.String(), rec2.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intern wurde aber nur fuer die existierende Adresse tatsaechlich benachrichtigt.
|
||||||
|
if len(notifier.calls) != 1 || notifier.calls[0] != u.Email {
|
||||||
|
t.Fatalf("erwartet genau 1 benachrichtigung fuer %q, habe %v", u.Email, notifier.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3: vollstaendiger Reset-Flow ueber HTTP.
|
||||||
|
func TestCompleteReset_Works(t *testing.T) {
|
||||||
|
tokens, users, userID, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
token, err := tokens.Create(ctx, userID, PurposeReset, ResetTokenTTL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("token erzeugen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(tokens, users, &fakeNotifier{})
|
||||||
|
body, _ := json.Marshal(completeResetRequest{Token: token, NewPassword: "neues-sicheres-passwort"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/auth/password-reset/complete", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.CompleteReset(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token ist jetzt verbraucht — zweiter Versuch schlaegt fehl.
|
||||||
|
body2, _ := json.Marshal(completeResetRequest{Token: token, NewPassword: "noch-ein-passwort"})
|
||||||
|
req2 := httptest.NewRequest(http.MethodPost, "/auth/password-reset/complete", bytes.NewReader(body2))
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
h.CompleteReset(rec2, req2)
|
||||||
|
if rec2.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("wiederverwendung: status = %d, want 400", rec2.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package totp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler stellt Login (inklusive optionalem zweiten Faktor) und die
|
||||||
|
// 2FA-Einrichtung als HTTP-Endpunkte bereit (IAM-08). Ersetzt auth.Handler.Login
|
||||||
|
// nicht (der bleibt fuer Faelle ohne 2FA-Bedarf nutzbar), bietet aber den
|
||||||
|
// EINEN Login-Endpunkt, den das Frontend tatsaechlich aufruft — LoginWithTOTP
|
||||||
|
// deckt beide Faelle (mit/ohne 2FA) bereits ab.
|
||||||
|
type Handler struct {
|
||||||
|
users *user.TenantUserStore
|
||||||
|
totp *Store
|
||||||
|
login *auth.LoginService
|
||||||
|
issuer string // fuer die otpauth://-Provisioning-URI (Akzeptanzkriterium 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(users *user.TenantUserStore, totpStore *Store, login *auth.LoginService, issuer string) *Handler {
|
||||||
|
return &Handler{users: users, totp: totpStore, login: login, issuer: issuer}
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
TOTPCode string `json:"totp_code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginErrorResponse struct {
|
||||||
|
// Error ist bewusst IMMER dieselbe generische Meldung fuer falsches
|
||||||
|
// Passwort/Token (Akzeptanzkriterium 3 / Pruefung 1) — Code unterscheidet
|
||||||
|
// intern zwischen "second_factor_required" (Formular soll TOTP-Feld
|
||||||
|
// einblenden) und "invalid_credentials" (alles andere), ohne dem Client
|
||||||
|
// mehr ueber den tatsaechlichen Fehlgrund zu verraten.
|
||||||
|
Error string `json:"error"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login ist der einzige Login-Endpunkt des Frontends (Akzeptanzkriterium 1).
|
||||||
|
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req loginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := LoginWithTOTP(r.Context(), h.users, h.totp, h.login, req.Email, req.Password, req.TOTPCode)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err == ErrSecondFactorRequired {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_ = json.NewEncoder(w).Encode(loginErrorResponse{
|
||||||
|
Error: "Anmeldedaten oder Code ungültig.",
|
||||||
|
Code: "second_factor_required",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_ = json.NewEncoder(w).Encode(loginErrorResponse{
|
||||||
|
Error: "Anmeldedaten oder Code ungültig.",
|
||||||
|
Code: "invalid_credentials",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: auth.CookieName,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
MaxAge: int(auth.AccessTokenTTL.Seconds()),
|
||||||
|
})
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusResponse struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status liefert, ob 2FA fuer den angemeldeten Benutzer aktiv ist — fuer die
|
||||||
|
// Profilseite (Akzeptanzkriterium 2: zeigt an, ob Einrichtung schon
|
||||||
|
// stattgefunden hat). Muss hinter auth.RequireAuth haengen.
|
||||||
|
func (h *Handler) Status(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := auth.ClaimsFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enabled, err := h.totp.IsEnabled(r.Context(), claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "status konnte nicht ermittelt werden", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(statusResponse{Enabled: enabled})
|
||||||
|
}
|
||||||
|
|
||||||
|
type setupBeginResponse struct {
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
ProvisioningURI string `json:"provisioning_uri"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupBegin startet die 2FA-Einrichtung (Akzeptanzkriterium 2) — muss hinter
|
||||||
|
// auth.RequireAuth haengen.
|
||||||
|
func (h *Handler) SetupBegin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := auth.ClaimsFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, err := h.users.Get(r.Context(), claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "benutzer nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
secret, uri, err := h.totp.BeginSetup(r.Context(), claims.UserID, h.issuer, u.Email)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "einrichtung konnte nicht gestartet werden", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(setupBeginResponse{Secret: secret, ProvisioningURI: uri})
|
||||||
|
}
|
||||||
|
|
||||||
|
type setupConfirmRequest struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type setupConfirmResponse struct {
|
||||||
|
RecoveryCodes []string `json:"recovery_codes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupConfirm bestaetigt die Einrichtung und liefert die Wiederherstellungscodes
|
||||||
|
// EINMALIG im Klartext (Akzeptanzkriterium 2) — muss hinter auth.RequireAuth haengen.
|
||||||
|
func (h *Handler) SetupConfirm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := auth.ClaimsFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req setupConfirmRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
codes, err := h.totp.ConfirmSetup(r.Context(), claims.UserID, req.Code)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Code ungültig.", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(setupConfirmResponse{RecoveryCodes: codes})
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package totp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
func emailForUser(t *testing.T, users *user.TenantUserStore, userID string) string {
|
||||||
|
t.Helper()
|
||||||
|
u, err := users.Get(context.Background(), userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("benutzer laden: %v", err)
|
||||||
|
}
|
||||||
|
return u.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
// authenticatedRequest baut einen Request mit echtem, gueltigem
|
||||||
|
// Session-Cookie (echter Issuer, echte Verifikation ueber auth.RequireAuth) —
|
||||||
|
// kein simulierter Context, dasselbe Muster wie internal/oidc (IAM-13).
|
||||||
|
func authenticatedRequest(t *testing.T, issuer *auth.TokenIssuer, userID string, req *http.Request) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
token, err := issuer.Issue(userID, "acme")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("session-token ausstellen: %v", err)
|
||||||
|
}
|
||||||
|
req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: token})
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + Pruefung 1: Login ohne 2FA funktioniert normal,
|
||||||
|
// Login mit falschem Passwort liefert die generische Fehlermeldung.
|
||||||
|
func TestHandlerLogin_WithoutTOTP(t *testing.T) {
|
||||||
|
store, users, userID, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
login := auth.NewLoginService(users, auth.NewTokenIssuer("test-secret"), "acme")
|
||||||
|
h := NewHandler(users, store, login, "NEXARCH")
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{"email": emailForUser(t, users, userID), "password": "korrektes-passwort"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.Login(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if len(rec.Result().Cookies()) == 0 {
|
||||||
|
t.Fatal("erwartet gesetztes session-cookie")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerLogin_WrongPassword_GivesGenericError(t *testing.T) {
|
||||||
|
store, users, userID, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
login := auth.NewLoginService(users, auth.NewTokenIssuer("test-secret"), "acme")
|
||||||
|
h := NewHandler(users, store, login, "NEXARCH")
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{"email": emailForUser(t, users, userID), "password": "falsches-passwort"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.Login(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status = %d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
var resp loginErrorResponse
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("response dekodieren: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Error != "Anmeldedaten oder Code ungültig." {
|
||||||
|
t.Fatalf("erwartet generische Fehlermeldung, habe %q (koennte interne Details preisgeben)", resp.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + 2: mit aktivem 2FA verlangt Login den Code,
|
||||||
|
// Setup-Confirm liefert die Wiederherstellungscodes EINMALIG.
|
||||||
|
func TestHandlerLogin_WithTOTP_RequiresCode(t *testing.T) {
|
||||||
|
store, users, userID, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
secret, _, err := store.BeginSetup(ctx, userID, "NEXARCH", "user@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("begin setup: %v", err)
|
||||||
|
}
|
||||||
|
code, err := GenerateCode(secret, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("code generieren: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.ConfirmSetup(ctx, userID, code); err != nil {
|
||||||
|
t.Fatalf("confirm setup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
login := auth.NewLoginService(users, auth.NewTokenIssuer("test-secret"), "acme")
|
||||||
|
h := NewHandler(users, store, login, "NEXARCH")
|
||||||
|
email := emailForUser(t, users, userID)
|
||||||
|
|
||||||
|
// Ohne Code: second_factor_required, kein Cookie.
|
||||||
|
body, _ := json.Marshal(map[string]string{"email": email, "password": "korrektes-passwort"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.Login(rec, req)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("ohne code: status = %d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
var resp loginErrorResponse
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
if resp.Code != "second_factor_required" {
|
||||||
|
t.Fatalf("erwartet code=second_factor_required, habe %q", resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mit gueltigem Code: Login gelingt.
|
||||||
|
freshCode, _ := GenerateCode(secret, time.Now())
|
||||||
|
body2, _ := json.Marshal(map[string]string{"email": email, "password": "korrektes-passwort", "totp_code": freshCode})
|
||||||
|
req2 := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body2))
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
h.Login(rec2, req2)
|
||||||
|
if rec2.Code != http.StatusOK {
|
||||||
|
t.Fatalf("mit code: status = %d, want 200, body: %s", rec2.Code, rec2.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerSetupConfirm_ReturnsRecoveryCodes(t *testing.T) {
|
||||||
|
store, users, userID, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
login := auth.NewLoginService(users, auth.NewTokenIssuer("test-secret"), "acme")
|
||||||
|
sessionIssuer := auth.NewTokenIssuer("test-session-secret")
|
||||||
|
h := NewHandler(users, store, login, "NEXARCH")
|
||||||
|
|
||||||
|
beginReq := authenticatedRequest(t, sessionIssuer, userID, httptest.NewRequest(http.MethodPost, "/auth/totp/setup/begin", nil))
|
||||||
|
beginRec := httptest.NewRecorder()
|
||||||
|
auth.RequireAuth(sessionIssuer, h.SetupBegin)(beginRec, beginReq)
|
||||||
|
if beginRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("setup begin: status = %d, body: %s", beginRec.Code, beginRec.Body.String())
|
||||||
|
}
|
||||||
|
var beginResp setupBeginResponse
|
||||||
|
if err := json.Unmarshal(beginRec.Body.Bytes(), &beginResp); err != nil {
|
||||||
|
t.Fatalf("begin-response dekodieren: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := GenerateCode(beginResp.Secret, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("code generieren: %v", err)
|
||||||
|
}
|
||||||
|
confirmBody, _ := json.Marshal(setupConfirmRequest{Code: code})
|
||||||
|
confirmReq := authenticatedRequest(t, sessionIssuer, userID,
|
||||||
|
httptest.NewRequest(http.MethodPost, "/auth/totp/setup/confirm", bytes.NewReader(confirmBody)))
|
||||||
|
confirmRec := httptest.NewRecorder()
|
||||||
|
auth.RequireAuth(sessionIssuer, h.SetupConfirm)(confirmRec, confirmReq)
|
||||||
|
|
||||||
|
if confirmRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("setup confirm: status = %d, body: %s", confirmRec.Code, confirmRec.Body.String())
|
||||||
|
}
|
||||||
|
var confirmResp setupConfirmResponse
|
||||||
|
if err := json.Unmarshal(confirmRec.Body.Bytes(), &confirmResp); err != nil {
|
||||||
|
t.Fatalf("confirm-response dekodieren: %v", err)
|
||||||
|
}
|
||||||
|
if len(confirmResp.RecoveryCodes) != RecoveryCodeCount {
|
||||||
|
t.Fatalf("erwartet %d wiederherstellungscodes, habe %d", RecoveryCodeCount, len(confirmResp.RecoveryCodes))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -126,6 +126,26 @@ type AuthCredentials struct {
|
|||||||
PasswordHash string
|
PasswordHash string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetByIDForAuth liefert Benutzer + Passwort-Hash zu einer User-ID — fuer
|
||||||
|
// IAM-08 (Passwortaenderung: die bestehende Session liefert die ID, nicht
|
||||||
|
// die E-Mail-Adresse, gleiches Prinzip wie GetByEmailForAuth).
|
||||||
|
func (s *TenantUserStore) GetByIDForAuth(ctx context.Context, id string) (AuthCredentials, error) {
|
||||||
|
var c AuthCredentials
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, email, name, status, created_at, updated_at, password_hash
|
||||||
|
FROM users WHERE id = $1
|
||||||
|
`, id)
|
||||||
|
|
||||||
|
if err := row.Scan(&c.User.ID, &c.User.Email, &c.User.Name, &c.User.Status,
|
||||||
|
&c.User.CreatedAt, &c.User.UpdatedAt, &c.PasswordHash); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return AuthCredentials{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return AuthCredentials{}, fmt.Errorf("anmeldedaten lesen: %w", err)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetByEmailForAuth liefert Benutzer + Passwort-Hash zu einer E-Mail-Adresse
|
// GetByEmailForAuth liefert Benutzer + Passwort-Hash zu einer E-Mail-Adresse
|
||||||
// aus GENAU DIESER Tenant-Datenbank — der Tenant-Scope ergibt sich damit
|
// aus GENAU DIESER Tenant-Datenbank — der Tenant-Scope ergibt sich damit
|
||||||
// zwingend aus dem verwendeten Pool, es gibt keine Moeglichkeit, versehentlich
|
// zwingend aus dem verwendeten Pool, es gibt keine Moeglichkeit, versehentlich
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "NEXARCH Anmeldung & Profil",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="de">
|
||||||
|
<body
|
||||||
|
style={{
|
||||||
|
fontFamily: typography.fontFamily,
|
||||||
|
margin: 0,
|
||||||
|
background: "var(--shl-color-background, #ffffff)",
|
||||||
|
color: "var(--shl-color-text-primary, #14181f)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemeProvider>
|
||||||
|
<I18nProvider initialLocale="de">
|
||||||
|
<ToastProvider>{children}</ToastProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { TextField, useToast } from "@nexarch/shl";
|
||||||
|
import { ApiError, login } from "../../lib/api";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const { push } = useToast();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [totpCode, setTotpCode] = useState("");
|
||||||
|
const [needsSecondFactor, setNeedsSecondFactor] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await login(email, password, needsSecondFactor ? totpCode : undefined);
|
||||||
|
push("Anmeldung erfolgreich.", "success");
|
||||||
|
window.location.href = "/profile";
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
// Akzeptanzkriterium 3 / Pruefung 1: nur die generische, vom Backend
|
||||||
|
// gelieferte Meldung anzeigen — keine eigene, evtl. praezisere
|
||||||
|
// Fehlerursache im Frontend herleiten oder ergaenzen.
|
||||||
|
setError(err.message);
|
||||||
|
if (err.code === "second_factor_required") {
|
||||||
|
setNeedsSecondFactor(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError("Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ maxWidth: 400, margin: "80px auto", padding: "0 16px" }}>
|
||||||
|
<h1>Anmeldung</h1>
|
||||||
|
<form onSubmit={onSubmit} noValidate>
|
||||||
|
<TextField
|
||||||
|
label="E-Mail-Adresse"
|
||||||
|
type="email"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Passwort"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{needsSecondFactor && (
|
||||||
|
<TextField
|
||||||
|
label="Zweiter Faktor (Authenticator-Code oder Wiederherstellungscode)"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
required
|
||||||
|
value={totpCode}
|
||||||
|
onChange={(e) => setTotpCode(e.target.value)}
|
||||||
|
hint="Öffnen Sie Ihre Authenticator-App oder geben Sie einen Wiederherstellungscode ein."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button type="submit" disabled={submitting}>
|
||||||
|
{submitting ? "Anmeldung läuft…" : "Anmelden"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p>
|
||||||
|
<a href="/password-reset/request">Passwort vergessen?</a>
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function IndexPage() {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { TextField } from "@nexarch/shl";
|
||||||
|
import { ApiError, completePasswordReset } from "../../../lib/api";
|
||||||
|
|
||||||
|
export default function PasswordResetCompletePage() {
|
||||||
|
const params = useSearchParams();
|
||||||
|
const token = params.get("token") ?? "";
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await completePasswordReset(token, newPassword);
|
||||||
|
setDone(true);
|
||||||
|
} catch (err) {
|
||||||
|
// Akzeptanzkriterium 3: generische Meldung, keine interne Fehlerursache.
|
||||||
|
setError(err instanceof ApiError ? err.message : "Der Link ist ungültig oder abgelaufen.");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
return (
|
||||||
|
<main style={{ maxWidth: 400, margin: "80px auto", padding: "0 16px" }}>
|
||||||
|
<h1>Passwort geändert</h1>
|
||||||
|
<p>
|
||||||
|
Ihr Passwort wurde erfolgreich geändert. <a href="/login">Jetzt anmelden</a>.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ maxWidth: 400, margin: "80px auto", padding: "0 16px" }}>
|
||||||
|
<h1>Neues Passwort festlegen</h1>
|
||||||
|
{!token && (
|
||||||
|
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
|
||||||
|
Kein gültiger Link. Bitte fordern Sie einen neuen Reset-Link an.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<form onSubmit={onSubmit} noValidate>
|
||||||
|
<TextField
|
||||||
|
label="Neues Passwort"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button type="submit" disabled={submitting || !token}>
|
||||||
|
{submitting ? "Wird gespeichert…" : "Passwort speichern"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { TextField } from "@nexarch/shl";
|
||||||
|
import { requestPasswordReset } from "../../../lib/api";
|
||||||
|
|
||||||
|
export default function PasswordResetRequestPage() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
// Antwort ist absichtlich IMMER dieselbe (Backend-Garantie,
|
||||||
|
// Akzeptanzkriterium 3) — das Frontend erfindet keine differenzierte
|
||||||
|
// Anzeige je nachdem ob das Konto existiert.
|
||||||
|
const result = await requestPasswordReset(email);
|
||||||
|
setMessage(result.message);
|
||||||
|
} catch {
|
||||||
|
setMessage("Falls ein Konto mit dieser E-Mail-Adresse existiert, wurde eine Nachricht mit weiteren Schritten verschickt.");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ maxWidth: 400, margin: "80px auto", padding: "0 16px" }}>
|
||||||
|
<h1>Passwort zurücksetzen</h1>
|
||||||
|
{message ? (
|
||||||
|
<p role="status">{message}</p>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={onSubmit} noValidate>
|
||||||
|
<TextField
|
||||||
|
label="E-Mail-Adresse"
|
||||||
|
type="email"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={submitting}>
|
||||||
|
{submitting ? "Wird gesendet…" : "Link anfordern"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<p>
|
||||||
|
<a href="/login">Zurück zur Anmeldung</a>
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { TextField, useToast } from "@nexarch/shl";
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
Me,
|
||||||
|
beginTotpSetup,
|
||||||
|
changePassword,
|
||||||
|
confirmTotpSetup,
|
||||||
|
fetchMe,
|
||||||
|
fetchTotpStatus,
|
||||||
|
} from "../../lib/api";
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const { push } = useToast();
|
||||||
|
const [me, setMe] = useState<Me | null>(null);
|
||||||
|
const [totpEnabled, setTotpEnabled] = useState<boolean | null>(null);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([fetchMe(), fetchTotpStatus()])
|
||||||
|
.then(([meResult, statusResult]) => {
|
||||||
|
setMe(meResult);
|
||||||
|
setTotpEnabled(statusResult.enabled);
|
||||||
|
})
|
||||||
|
.catch(() => setLoadError("Bitte melden Sie sich an, um Ihr Profil zu sehen."));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loadError) {
|
||||||
|
return (
|
||||||
|
<main style={{ maxWidth: 480, margin: "80px auto", padding: "0 16px" }}>
|
||||||
|
<p role="alert">{loadError}</p>
|
||||||
|
<a href="/login">Zur Anmeldung</a>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ maxWidth: 480, margin: "40px auto", padding: "0 16px" }}>
|
||||||
|
<h1>Mein Profil</h1>
|
||||||
|
{me && (
|
||||||
|
<section aria-labelledby="stammdaten-heading">
|
||||||
|
<h2 id="stammdaten-heading">Stammdaten</h2>
|
||||||
|
<p>
|
||||||
|
{me.name} — {me.email}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ChangePasswordSection onSuccess={() => push("Passwort geändert.", "success")} />
|
||||||
|
|
||||||
|
<TotpSection
|
||||||
|
enabled={totpEnabled}
|
||||||
|
onEnabled={() => setTotpEnabled(true)}
|
||||||
|
onNotice={(text) => push(text, "info")}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChangePasswordSection({ onSuccess }: { onSuccess: () => void }) {
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await changePassword(currentPassword, newPassword);
|
||||||
|
setCurrentPassword("");
|
||||||
|
setNewPassword("");
|
||||||
|
onSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "Passwort konnte nicht geändert werden.");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-labelledby="passwort-heading">
|
||||||
|
<h2 id="passwort-heading">Passwort ändern</h2>
|
||||||
|
<form onSubmit={onSubmit} noValidate>
|
||||||
|
<TextField
|
||||||
|
label="Aktuelles Passwort"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Neues Passwort"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button type="submit" disabled={submitting}>
|
||||||
|
{submitting ? "Wird gespeichert…" : "Passwort ändern"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TotpSection({
|
||||||
|
enabled,
|
||||||
|
onEnabled,
|
||||||
|
onNotice,
|
||||||
|
}: {
|
||||||
|
enabled: boolean | null;
|
||||||
|
onEnabled: () => void;
|
||||||
|
onNotice: (text: string) => void;
|
||||||
|
}) {
|
||||||
|
const [setupData, setSetupData] = useState<{ secret: string; provisioningUri: string } | null>(null);
|
||||||
|
const [confirmCode, setConfirmCode] = useState("");
|
||||||
|
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function onBeginSetup() {
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await beginTotpSetup();
|
||||||
|
setSetupData({ secret: result.secret, provisioningUri: result.provisioning_uri });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "Einrichtung konnte nicht gestartet werden.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onConfirm(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await confirmTotpSetup(confirmCode);
|
||||||
|
// Akzeptanzkriterium 2: Wiederherstellungscodes werden HIER, EINMALIG
|
||||||
|
// im Klartext angezeigt — die Backend-API liefert sie an keiner
|
||||||
|
// anderen Stelle je wieder aus (siehe internal/totp/store.go).
|
||||||
|
setRecoveryCodes(result.recovery_codes);
|
||||||
|
setSetupData(null);
|
||||||
|
onEnabled();
|
||||||
|
onNotice("Zwei-Faktor-Authentifizierung aktiviert. Bewahren Sie die Wiederherstellungscodes sicher auf.");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "Code ungültig.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-labelledby="zwei-faktor-heading">
|
||||||
|
<h2 id="zwei-faktor-heading">Zwei-Faktor-Authentifizierung</h2>
|
||||||
|
|
||||||
|
{recoveryCodes ? (
|
||||||
|
<div role="alert">
|
||||||
|
<p>
|
||||||
|
<strong>Wiederherstellungscodes — jetzt notieren, sie werden nicht erneut angezeigt:</strong>
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
{recoveryCodes.map((code) => (
|
||||||
|
<li key={code}>
|
||||||
|
<code>{code}</code>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : enabled ? (
|
||||||
|
<p>Zwei-Faktor-Authentifizierung ist aktiv.</p>
|
||||||
|
) : setupData ? (
|
||||||
|
<form onSubmit={onConfirm} noValidate>
|
||||||
|
<p>
|
||||||
|
Scannen Sie den folgenden Schlüssel mit Ihrer Authenticator-App oder geben Sie ihn manuell ein:
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<code>{setupData.secret}</code>
|
||||||
|
</p>
|
||||||
|
<p style={{ wordBreak: "break-all" }}>
|
||||||
|
<a href={setupData.provisioningUri}>{setupData.provisioningUri}</a>
|
||||||
|
</p>
|
||||||
|
<TextField
|
||||||
|
label="Bestätigungscode aus der App"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
required
|
||||||
|
value={confirmCode}
|
||||||
|
onChange={(e) => setConfirmCode(e.target.value)}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button type="submit" disabled={busy}>
|
||||||
|
{busy ? "Wird bestätigt…" : "Einrichtung bestätigen"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p>Zwei-Faktor-Authentifizierung ist derzeit nicht aktiv.</p>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={onBeginSetup} disabled={busy}>
|
||||||
|
{busy ? "Wird gestartet…" : "Einrichten"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Duenne fetch-Wrapper gegen die IAM-08-Backend-Endpunkte. Kein Framework,
|
||||||
|
// keine Abstraktionsschicht ueber das Notwendige hinaus (Produkt-DNA:
|
||||||
|
// schlanker Go-Dienst + schmales Frontend statt schwergewichtiger Plattform).
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_CORE_API_BASE ?? "";
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
code?: string;
|
||||||
|
constructor(message: string, code?: string) {
|
||||||
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJSON<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(data.error ?? data.message ?? "Unbekannter Fehler", data.code);
|
||||||
|
}
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJSON<T>(path: string): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, { credentials: "include" });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(data.error ?? data.message ?? "Unbekannter Fehler", data.code);
|
||||||
|
}
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResult {
|
||||||
|
ok: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(email: string, password: string, totpCode?: string): Promise<LoginResult> {
|
||||||
|
await postJSON("/auth/login", { email, password, totp_code: totpCode ?? "" });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestPasswordReset(email: string): Promise<{ message: string }> {
|
||||||
|
return postJSON("/auth/password-reset/request", { email });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completePasswordReset(token: string, newPassword: string): Promise<void> {
|
||||||
|
await postJSON("/auth/password-reset/complete", { token, new_password: newPassword });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||||
|
await postJSON("/account/change-password", { current_password: currentPassword, new_password: newPassword });
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Me {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMe(): Promise<Me> {
|
||||||
|
return getJSON("/account/me");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TotpStatus {
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTotpStatus(): Promise<TotpStatus> {
|
||||||
|
return getJSON("/auth/totp/status");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TotpSetupBegin {
|
||||||
|
secret: string;
|
||||||
|
provisioning_uri: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function beginTotpSetup(): Promise<TotpSetupBegin> {
|
||||||
|
return postJSON("/auth/totp/setup/begin", {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TotpSetupConfirm {
|
||||||
|
recovery_codes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmTotpSetup(code: string): Promise<TotpSetupConfirm> {
|
||||||
|
return postJSON("/auth/totp/setup/confirm", { code });
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
// @nexarch/shl liegt als file:-Dependency mit TS-Quellen in node_modules —
|
||||||
|
// Next.js transpiliert node_modules standardmäßig nicht, siehe web/shl/README.md.
|
||||||
|
transpilePackages: ["@nexarch/shl"],
|
||||||
|
};
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "nexarch-account",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nexarch/shl": "file:../shl",
|
||||||
|
"next": "14.2.35",
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-dom": "18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "20.14.9",
|
||||||
|
"@types/react": "18.3.3",
|
||||||
|
"@types/react-dom": "18.3.0",
|
||||||
|
"typescript": "5.5.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": { "@/*": ["./*"] }
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user