Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions cypress/e2e/auth_spec.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,53 @@ describe('login using form', function () {
})

})

// GET /api/auth/whoami must report identity from the eXist subject (sm:id()),
// not the persistent-login request attribute. The attribute is only populated
// by the cookie/param flow, so before this fix a request authenticated with the
// HTTP Basic header reported as "guest" even though it executed as the real
// user. These assert identity is consistent across all three auth mechanisms.
describe('whoami identity source (sm:id, not the login attribute)', function () {
const whoami = '/eXide/api/auth/whoami'

it('reports the real user under HTTP Basic auth (regression)', function () {
cy.request({
url: whoami,
auth: { user: 'admin', pass: '' }
}).then((res) => {
expect(res.body.user).to.eq('admin')
expect(res.body.isLoggedIn).to.eq(true)
expect(res.body.isAdmin).to.eq(true)
})
})

it('reports guest when unauthenticated', function () {
// Cypress shares a cookie jar across requests; clear it so no
// remember-me cookie leaks into this assertion.
cy.clearCookies()
cy.request({ url: whoami }).then((res) => {
expect(res.body.user).to.eq('guest')
expect(res.body.isLoggedIn).to.eq(false)
expect(res.body.isAdmin).to.eq(false)
})
})

it('reports the real user via the persistent-login cookie', function () {
cy.clearCookies()
// Mint the cookie the way the login form does (form params).
cy.request({
method: 'POST',
url: '/eXide/api/auth/session',
form: true,
body: { user: 'admin', password: '' }
}).then((res) => {
expect(res.body.user).to.eq('admin')
})
// Subsequent request carries the cookie; whoami must agree.
cy.request({ url: whoami }).then((res) => {
expect(res.body.user).to.eq('admin')
expect(res.body.isLoggedIn).to.eq(true)
expect(res.body.isAdmin).to.eq(true)
})
})
})
58 changes: 40 additions & 18 deletions modules/api/auth.xqm
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,35 @@ xquery version "3.1";

module namespace auth="http://exist-db.org/apps/eXide/api/auth";

import module namespace login="http://exist-db.org/xquery/login"
at "resource:org/exist/xquery/modules/persistentlogin/login.xql";
import module namespace roaster="http://e-editiones.org/roaster";
import module namespace config="http://exist-db.org/xquery/apps/config" at "../config.xqm";

declare variable $auth:LOGIN_DOMAIN := "org.exist.login";

declare function auth:get-user() as xs:string? {
let $_ := login:set-user($auth:LOGIN_DOMAIN, xs:dayTimeDuration("P7D"), false())
return request:get-attribute($auth:LOGIN_DOMAIN || ".user")
(:~
: The current request's identity, derived from the actual eXist subject
: (`sm:id()`) rather than the persistent-login request attribute.
:
: This matters: the attribute (`org.exist.login.user`) is populated only by
: the persistent-login flow (login form params or the remember-me cookie), so
: a request authenticated by the HTTP Basic header reported as `guest` even
: though it executed as the real user. `sm:id()` reflects whatever
: authenticated the request — Basic header, the shared `org.exist.login`
: cookie (processed by controller.xq before this handler runs), or a freshly
: minted login session — uniformly. This is the same identity basis used by
: Roaster's `rutil:getDBUser()` and by existdb-openapi, so eXide's notion of
: "who" now matches the rest of the stack.
:
: `sm:effective` is preferred over `sm:real` because token/cookie logins set
: the effective user (same reasoning as Roaster's getDBUser).
:)
declare function auth:current-user() as map(*) {
let $id := sm:id()/sm:id
let $principal := ($id/sm:effective, $id/sm:real)[1]
let $name := ($principal/sm:username/string(), "guest")[1]
return map {
"name": $name,
"isAdmin": sm:is-dba($name),
"isLoggedIn": not($name = ("guest", "nobody"))
}
};

declare function auth:is-allowed($user as xs:string?) as xs:boolean {
Expand All @@ -27,14 +46,18 @@ declare function auth:is-allowed($user as xs:string?) as xs:boolean {

(:~
: POST /api/auth/session — Login.
:
: The persistent-login cookie is minted by controller.xq's `login:set-user`,
: which reads the `user` / `password` / `duration` form parameters before
: forwarding here. This handler reports the resulting identity.
:)
declare function auth:login($request as map(*)) {
let $user := auth:get-user()
let $user := auth:current-user()
return
if (auth:is-allowed($user)) then
if (auth:is-allowed($user?name)) then
map {
"user": ($user, "guest")[1],
"isAdmin": sm:is-dba(($user, "guest")[1])
"user": $user?name,
"isAdmin": $user?isAdmin
}
else
roaster:response(401, "application/json",
Expand All @@ -57,15 +80,14 @@ declare function auth:logout($request as map(*)) {
: GET /api/auth/whoami — Current user info.
:)
declare function auth:whoami($request as map(*)) {
let $user := auth:get-user()
let $user := auth:current-user()
let $conf := config:get-configuration()
let $user-to-check := ($user, "guest")[1]
return map {
"user": $user-to-check,
"isAdmin": sm:is-dba($user-to-check),
"isLoggedIn": exists($user) and not($user = ("guest", "nobody")),
"queryExecution": sm:is-dba($user-to-check) or (
$conf/restrictions/@execute-query = "yes" and auth:is-allowed($user)
"user": $user?name,
"isAdmin": $user?isAdmin,
"isLoggedIn": $user?isLoggedIn,
"queryExecution": $user?isAdmin or (
$conf/restrictions/@execute-query = "yes" and auth:is-allowed($user?name)
)
}
};
Loading