Add useEffect hook to fetch bootstrap status on component mount. Add bootstrapAvailable and bootstrapStatusLoaded state variables to track bootstrap endpoint availability. Hide mode toggle button when bootstrap is unavailable or status hasn't loaded yet. Add auth-brand and auth-brand-copy CSS classes to improve login page layout and branding. Add BootstrapStatus handler and BootstrapAvailable service method to expose bootstrap availability
150 lines
4.3 KiB
Go
150 lines
4.3 KiB
Go
package auth
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
|
|
"nexavpn/backend/internal/apiutil"
|
|
"nexavpn/backend/internal/audit"
|
|
"nexavpn/backend/internal/requestctx"
|
|
)
|
|
|
|
type Handler struct {
|
|
service *Service
|
|
audit *audit.Service
|
|
}
|
|
|
|
func NewHandler(service *Service, auditService *audit.Service) *Handler {
|
|
return &Handler{service: service, audit: auditService}
|
|
}
|
|
|
|
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
|
var input LoginRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
|
|
return
|
|
}
|
|
|
|
response, err := h.service.Login(r.Context(), input.Username, input.Password, r.RemoteAddr, r.UserAgent())
|
|
if err != nil {
|
|
_ = h.audit.Record(r.Context(), audit.Entry{
|
|
EventType: "auth.login.failed",
|
|
EntityType: "user",
|
|
Status: "failed",
|
|
Message: "user login failed",
|
|
Metadata: map[string]any{
|
|
"username": input.Username,
|
|
},
|
|
})
|
|
apiutil.Error(w, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
|
|
return
|
|
}
|
|
|
|
_ = h.audit.Record(r.Context(), audit.Entry{
|
|
ActorUserID: &response.User.ID,
|
|
EventType: "auth.login",
|
|
EntityType: "user",
|
|
EntityID: &response.User.ID,
|
|
Status: "success",
|
|
Message: "user login succeeded",
|
|
})
|
|
apiutil.JSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
func (h *Handler) Bootstrap(w http.ResponseWriter, r *http.Request) {
|
|
var input BootstrapRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
|
|
return
|
|
}
|
|
if input.Username == "" || input.Password == "" {
|
|
apiutil.Error(w, http.StatusBadRequest, "validation_error", "username and password are required")
|
|
return
|
|
}
|
|
if input.DisplayName == "" {
|
|
input.DisplayName = input.Username
|
|
}
|
|
|
|
user, err := h.service.BootstrapAdmin(r.Context(), input.Username, input.DisplayName, input.Password)
|
|
if err != nil {
|
|
apiutil.Error(w, http.StatusConflict, "bootstrap_failed", "initial admin already exists or could not be created")
|
|
return
|
|
}
|
|
|
|
_ = h.audit.Record(r.Context(), audit.Entry{
|
|
ActorUserID: &user.ID,
|
|
EntityType: "user",
|
|
EntityID: &user.ID,
|
|
EventType: "system.bootstrap_admin",
|
|
Status: "success",
|
|
Message: "initial admin account created",
|
|
})
|
|
apiutil.JSON(w, http.StatusCreated, user)
|
|
}
|
|
|
|
func (h *Handler) BootstrapStatus(w http.ResponseWriter, r *http.Request) {
|
|
available, err := h.service.BootstrapAvailable(r.Context())
|
|
if err != nil {
|
|
apiutil.Error(w, http.StatusInternalServerError, "bootstrap_status_failed", "unable to determine bootstrap status")
|
|
return
|
|
}
|
|
|
|
apiutil.JSON(w, http.StatusOK, map[string]any{
|
|
"bootstrap_available": available,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
|
|
var input RefreshRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
|
|
return
|
|
}
|
|
|
|
response, err := h.service.Refresh(r.Context(), input.RefreshToken)
|
|
if err != nil {
|
|
apiutil.Error(w, http.StatusUnauthorized, "invalid_refresh_token", "unable to refresh session")
|
|
return
|
|
}
|
|
|
|
apiutil.JSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
|
var input RefreshRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
|
|
return
|
|
}
|
|
|
|
if err := h.service.Logout(r.Context(), input.RefreshToken); err != nil {
|
|
apiutil.Error(w, http.StatusBadRequest, "logout_failed", "unable to revoke session")
|
|
return
|
|
}
|
|
|
|
if claims, ok := requestctx.ClaimsFromContext(r.Context()); ok {
|
|
_ = h.audit.Record(r.Context(), audit.Entry{
|
|
ActorUserID: &claims.UserID,
|
|
EventType: "auth.logout",
|
|
EntityType: "session",
|
|
Status: "success",
|
|
Message: "session logout succeeded",
|
|
})
|
|
}
|
|
apiutil.JSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
|
|
claims, ok := requestctx.ClaimsFromContext(r.Context())
|
|
if !ok {
|
|
apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "missing auth claims")
|
|
return
|
|
}
|
|
|
|
apiutil.JSON(w, http.StatusOK, map[string]any{
|
|
"id": claims.UserID,
|
|
"username": claims.Username,
|
|
"role": claims.Role,
|
|
})
|
|
}
|