feat(10-01): serve the embedded admin SPA at backend.uri with cookie login
- backend.uri prefix (default /backend) mounts the admin API at {prefix}/api/v1
and the embedded SPA shell at {prefix} with an api/ JSON 404 fallback
- cookie transport: an X-Requested-With login sets the HttpOnly summer_admin
cookie and returns no token; the backend guard reads the cookie after Bearer
- CSRF wrapper refuses cookie-only POST/PUT/DELETE without X-Requested-With
- boardwalk package embeds boardwalk/dist, rewrites index.html once per prefix
and sets cache and security headers
- framework admin OpenAPI pipeline (swag, swagger2openapi, openapi-typescript)
with prefix-relative paths and typed envelopes for the tracer routes
- admin/ Vite SPA: login, plugin rail, section panel and read-only list
through the openapi-fetch client typed by the generated schema
This commit is contained in:
@@ -172,12 +172,39 @@ func (s *service) login(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.logAuth(r, "success", user.ID)
|
||||
if isAjax(r) {
|
||||
// Cookie transport (D-19): the SPA never sees the token.
|
||||
s.writeSessionCookie(w, token)
|
||||
WriteData(w, http.StatusOK, cookieLoginData(s.ttl), map[string]any{})
|
||||
return
|
||||
}
|
||||
WriteData(w, http.StatusOK, map[string]string{
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
}, map[string]any{})
|
||||
}
|
||||
|
||||
// cookieLoginData is the login/refresh body under cookie transport: no token,
|
||||
// only its type and the access lifetime in seconds.
|
||||
func cookieLoginData(ttl time.Duration) AdminLoginData {
|
||||
return AdminLoginData{TokenType: "cookie", ExpiresIn: int(ttl / time.Second)}
|
||||
}
|
||||
|
||||
// writeSessionCookie sets the admin JWT cookie scoped to the admin prefix.
|
||||
// Max-Age is the refresh window, because refresh accepts an expired access
|
||||
// token until iat plus refresh_ttl.
|
||||
func (s *service) writeSessionCookie(w http.ResponseWriter, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: AdminCookieName,
|
||||
Value: token,
|
||||
Path: s.adminPrefix(),
|
||||
MaxAge: int(s.refreshTTL / time.Second),
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *service) refresh(w http.ResponseWriter, r *http.Request) {
|
||||
raw := bearerToken(r)
|
||||
if raw == "" {
|
||||
@@ -247,23 +274,19 @@ func (s *service) me(w http.ResponseWriter, r *http.Request) {
|
||||
WriteData(w, http.StatusOK, profileOf(user), map[string]any{})
|
||||
}
|
||||
|
||||
func profileOf(user BackendUser) map[string]any {
|
||||
data := map[string]any{
|
||||
"id": user.ID,
|
||||
"login": user.Login,
|
||||
"email": user.Email,
|
||||
"first_name": user.FirstName,
|
||||
"last_name": user.LastName,
|
||||
"is_superuser": user.IsSuperuser,
|
||||
func profileOf(user BackendUser) AdminProfile {
|
||||
profile := AdminProfile{
|
||||
ID: user.ID,
|
||||
Login: user.Login,
|
||||
Email: user.Email,
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
IsSuperuser: user.IsSuperuser,
|
||||
}
|
||||
if user.Role.ID != 0 {
|
||||
data["role"] = map[string]any{
|
||||
"id": user.Role.ID,
|
||||
"code": user.Role.Code,
|
||||
"name": user.Role.Name,
|
||||
}
|
||||
profile.Role = &AdminRoleSummary{ID: user.Role.ID, Code: user.Role.Code, Name: user.Role.Name}
|
||||
}
|
||||
return data
|
||||
return profile
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
@@ -402,15 +425,18 @@ func adminLoginWindow(app *backpack.App) (int, int) {
|
||||
return maxAttempts, decayMinutes
|
||||
}
|
||||
|
||||
func adminIssuer(app *backpack.App) string {
|
||||
// adminIssuer is app.url plus the admin API login path. JWT verification does
|
||||
// not check iss, so tokens minted under an earlier prefix stay valid until
|
||||
// they expire.
|
||||
func adminIssuer(app *backpack.App, prefix string) string {
|
||||
base := ""
|
||||
if app != nil && app.Config != nil {
|
||||
base = strings.TrimRight(strings.TrimSpace(app.Config.String("app.url")), "/")
|
||||
}
|
||||
if base == "" {
|
||||
return "/_admin/api/v1/auth/login"
|
||||
if prefix == "" {
|
||||
prefix = DefaultAdminPrefix
|
||||
}
|
||||
return base + "/_admin/api/v1/auth/login"
|
||||
return base + prefix + adminAPIVersion + "/auth/login"
|
||||
}
|
||||
|
||||
// dummyPasswordHash keeps a missing-user login on the bcrypt path.
|
||||
|
||||
Reference in New Issue
Block a user