feat(api/v2): add login and logout on /api/v2

Port the cookie-setting login and logout endpoints to Huma. Both reuse the
shared auth cores; the HttpOnly refresh cookie and Cache-Control: no-store
header are set via the unwrapped echo context (the cookie stays out of the
OpenAPI schema, matching v1). The token response inlines the JWT to avoid a
schema-name collision with user.Token.

login is public (LDAP-only deployments log in here too); logout inherits the
global JWT auth and no-ops for tokens that carry no session.
This commit is contained in:
kolaente 2026-06-12 10:28:29 +02:00
parent 1aa3f3b093
commit 5395bd37f3
3 changed files with 142 additions and 12 deletions

View File

@ -0,0 +1,129 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package apiv2
import (
"context"
"net/http"
"code.vikunja.io/api/pkg/modules/auth"
"code.vikunja.io/api/pkg/modules/humaecho5"
"code.vikunja.io/api/pkg/routes/api/shared"
"code.vikunja.io/api/pkg/user"
"github.com/danielgtaylor/huma/v2"
"github.com/labstack/echo/v5"
)
// authTokenBody wraps the issued user JWT. The token is inlined rather than
// embedding auth.Token because Huma derives schema names from the bare Go type
// name and a top-level auth.Token body would collide with user.Token (the
// caldav-token schema, also named "Token"). The refresh token is delivered out
// of band as an HttpOnly cookie, so it is intentionally absent from the schema.
type authTokenBody struct {
// Cache-Control: no-store keeps the access token out of any shared cache.
CacheControl string `header:"Cache-Control"`
Body struct {
Token string `json:"token" readOnly:"true" doc:"The short-lived JWT auth token. Send it as a bearer token on subsequent requests."`
}
}
// logoutBody confirms a successful logout.
type logoutBody struct {
Body struct {
Message string `json:"message" readOnly:"true" doc:"A human-readable confirmation message."`
}
}
func init() { AddRouteRegistrar(RegisterLoginRoutes) }
// RegisterLoginRoutes wires the local/LDAP login and logout endpoints. Login is
// always registered (LDAP-only deployments still log in here); logout inherits
// the global JWT auth.
func RegisterLoginRoutes(api huma.API) {
tags := []string{"auth"}
Register(api, huma.Operation{
OperationID: "auth-login",
Summary: "Login",
Description: "Logs a user in with username and password (and a TOTP passcode when 2FA is enabled), returning a short-lived JWT. A long-lived refresh token is set as an HttpOnly cookie scoped to the refresh endpoint.",
Method: http.MethodPost,
Path: "/login",
DefaultStatus: http.StatusOK,
Tags: tags,
Security: publicSecurity,
}, authLogin)
Register(api, huma.Operation{
OperationID: "auth-logout",
Summary: "Logout",
Description: "Destroys the current session server-side and clears the refresh-token cookie. A no-op for API tokens and link shares, which carry no session.",
Method: http.MethodPost,
Path: "/logout",
DefaultStatus: http.StatusOK,
Tags: tags,
}, authLogout)
}
func authLogin(ctx context.Context, in *struct{ Body user.Login }) (*authTokenBody, error) {
u, err := shared.AuthenticateUserCredentials(ctx, &in.Body)
if err != nil {
return nil, translateDomainError(err)
}
deviceInfo, ipAddress := requestClientInfo(ctx)
token, err := auth.IssueUserToken(ctx, u, deviceInfo, ipAddress, in.Body.LongToken)
if err != nil {
return nil, translateDomainError(err)
}
if ec := echoContextFromCtx(ctx); ec != nil {
auth.WriteUserAuthCookies(ec, token)
}
out := &authTokenBody{CacheControl: "no-store"}
out.Body.Token = token.AccessToken
return out, nil
}
func authLogout(ctx context.Context, _ *struct{}) (*logoutBody, error) {
var sid string
if ec := echoContextFromCtx(ctx); ec != nil {
auth.ClearRefreshTokenCookie(ec)
sid = auth.SessionIDFromContext(ec)
}
if err := shared.DeleteSession(sid); err != nil {
return nil, translateDomainError(err)
}
out := &logoutBody{}
out.Body.Message = "Successfully logged out."
return out, nil
}
// echoContextFromCtx pulls the underlying *echo.Context off a Huma request
// context so a handler can set cookies and headers the OpenAPI schema does not
// model (the refresh-token cookie). Returns nil when the context carries no echo
// context (it always does under the humaecho5 adapter).
func echoContextFromCtx(ctx context.Context) *echo.Context {
ec, ok := ctx.Value(humaecho5.EchoContextKey).(*echo.Context)
if !ok || ec == nil {
return nil
}
return ec
}

View File

@ -21,11 +21,9 @@ import (
"net/http"
"code.vikunja.io/api/pkg/modules/auth/oauth2server"
"code.vikunja.io/api/pkg/modules/humaecho5"
"code.vikunja.io/api/pkg/user"
"github.com/danielgtaylor/huma/v2"
"github.com/labstack/echo/v5"
)
// oauthTokenBody wraps the OAuth 2.0 token response.
@ -101,11 +99,12 @@ func oauthAuthorize(ctx context.Context, in *struct{ Body oauth2server.Authorize
}
// requestClientInfo pulls the user agent and client IP off the underlying Echo
// request so the authorization_code grant can record them on the session it
// creates, mirroring v1. Both fall back to "" when the context is unavailable.
// request so the authorization_code grant (and login) can record them on the
// session they create, mirroring v1. Both fall back to "" when the context is
// unavailable.
func requestClientInfo(ctx context.Context) (deviceInfo, ipAddress string) {
ec, ok := ctx.Value(humaecho5.EchoContextKey).(*echo.Context)
if !ok || ec == nil {
ec := echoContextFromCtx(ctx)
if ec == nil {
return "", ""
}
return (*ec).Request().UserAgent(), (*ec).RealIP()

View File

@ -354,12 +354,14 @@ var unauthenticatedAPIPaths = map[string]bool{
"/api/v2/schemas/:schema": true,
"/api/v2/info": true,
"/api/v2/register": true,
"/api/v2/user/password/token": true,
"/api/v2/user/password/reset": true,
"/api/v2/user/confirm": true,
"/api/v2/shares/:share/auth": true,
"/api/v2/oauth/token": true,
"/api/v2/register": true,
"/api/v2/user/password/token": true,
"/api/v2/user/password/reset": true,
"/api/v2/user/confirm": true,
"/api/v2/shares/:share/auth": true,
"/api/v2/oauth/token": true,
"/api/v2/login": true,
"/api/v2/auth/openid/:provider/callback": true,
// Testing endpoints authenticate with the testing token via a custom
// Authorization header, not a JWT; mounted only when that token is set.