Skip to content
Merged
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,25 @@ version, see the upgrade guides in `docs/`.
- **utils:** `capitalize()` doesn't start new words after digits, e.g. `capitalize('the 1st place')` returns `'The 1st Place'`, where it returned `'The 1St Place'` (#356).
- **utils:** `escapeHtml()` escapes single quotes as `'`, so its result is safe inside single-quoted attributes, where it left them unescaped (#359).
- **utils:** `isAbsoluteUrl()` only accepts letters, digits, plus, period and hyphen in schemes, e.g. `isAbsoluteUrl('a,b:c')` returns `false`, where commas were accepted too (#360).
- **server:** `UserMixin.login()` reads the credentials only from the request body, where Passport's local strategy also read them from the query string.
- **server:** Dito.js logs users in and restores them from the session itself, instead of through koa-passport. Passport's `ctx.req.user` mirror of `ctx.state.user` and custom Passport strategies through Dito.js are gone.
- **server:** `UsersController` only treats users of its own model class as logged in, not users of its sub-classes, which have their own tables and ids. This affects apps that serve sub-classed user models through the parent's `UsersController`: their `session`, `self` and `logout` actions no longer mistake such users for the parent's user with the same id.
- **server:** `UserMixin.sessionQuery()` is internal: `sessionScope` sets the scopes of the users of logins and sessions.

### New

- **ui:** `DitoMenuButton` takes `floating`, which opens the menu below the button as a separate panel with a tip pointing at the button (#344).
- **admin:** The account menu opens as a floating panel, right-aligned with the user name, and leaves the name unchanged while it is open (#344).
- **utils:** `isTypedArray()` checks for typed arrays, e.g. `Uint8Array`, including Node.js buffers (#350).

### Deprecations

- **server:** `app.passport` has no effect: users are always handled, with sessions when `app.session` is set.

### Improvements

- **server:** `@ditojs/server` no longer depends on `koa-passport` and `passport-local`. Existing sessions stay valid.

### Fixes

- **router:** A trailing slash of the `prefix` option is removed unless in strict mode, so `new Router({ prefix: '/api/' })` routes `/users` as `/api/users` instead of `/api//users` (#336).
Expand Down
8 changes: 4 additions & 4 deletions docs/api/server/application.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ controllers. Nothing is initialized until [`setup()`](#setup) or
| `validator` | [`Validator`](validator.md) | A custom validator. Default: `new Validator()`. |
| `router` | `object` | A custom router. The app calls its `add(method, path, route)` with the upper-case method and a route object, and `find(method, path)`, which returns `{ status, handler, params, allowed }` with the route object as `handler`. Default: a `@ditojs/router` instance. |
| `events` | `Record<string, Function \| Function[]>` | Event listeners, keyed by event name. Several comma-separated names may share one key. See [Events](#events). |
| `middleware` | `Koa.Middleware` | Application-level middleware, installed after the route lookup and before the body parser, transactions, session and passport. Combine several with [koa-compose](https://github.com/koajs/compose). |
| `middleware` | `Koa.Middleware` | Application-level middleware, installed after the route lookup and before the body parser, transactions, session and user handling. Combine several with [koa-compose](https://github.com/koajs/compose). |
| `services` | `Record<string, Service \| Class<Service>>` | Services to add, see [`addServices()`](#addservices). |
| `models` | `Record<string, Class<Model>>` | Models to add, see [`addModels()`](#addmodels). |
| `controllers` | `ApplicationControllers` | Controllers to add, see [`addControllers()`](#addcontrollers). |
Expand Down Expand Up @@ -105,7 +105,7 @@ augmentation of `ApplicationConfig` for project-specific settings.
| `etag` | `boolean` | `true` | Conditional GET and ETag headers. |
| `bodyParser` | `object` | | Options of [koa-bodyparser](https://github.com/koajs/bodyparser#options). |
| `session` | `boolean \| object` | `false` | Sessions through [koa-session](https://github.com/koajs/session). `modelClass` (a name or a model class) stores them in a model, e.g. one using [`SessionMixin`](model.md#sessionmixin). `autoCommit` (default `true`) commits the session after each request, on transacted routes only when the request succeeds. |
| `passport` | `boolean` | `false` | Authentication through koa-passport, with sessions when `session` is set. Needed for [`UserMixin`](model.md#usermixin). |
| `passport` | `boolean` | | Deprecated, without effect: users are always handled, see [`UserMixin`](model.md#usermixin). |

### config.admin

Expand Down Expand Up @@ -859,8 +859,8 @@ app.setupMiddleware(middleware?)

Installs the middleware: logger, response time, request logging, error handling,
context extensions, helmet, CORS, compression, ETag, request storage, route
lookup, the passed `middleware`, body parser, transactions, session, passport
with the user handling, and the route handler. Middleware added later through
lookup, the passed `middleware`, body parser, transactions, session, the user
handling, and the route handler. Middleware added later through
Koa's `app.use()` sees the requests that the route handler passes on, e.g. those
without a route. *Hook.*

Expand Down
4 changes: 2 additions & 2 deletions docs/api/server/controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ Requests of actions have these members on the Koa context:
| `ctx.controller` | The controller. |
| `ctx.params` | The route parameters, e.g. `{ id: '1' }`. |
| `ctx.route` | The route, see [`addRoute()`](./application.md#addroute). |
| `ctx.logger` | The logger of the request, with its request id (and the user with `app.passport`). |
| `ctx.logger` | The logger of the request, with its request id and user. |
| `ctx.memberId` | The validated member id, in member actions. |
| `ctx.filteredQuery` | The query parameters not consumed by `parameters`. |
| `ctx.transaction` | The transaction of transacted actions. |
Expand Down Expand Up @@ -175,7 +175,7 @@ type ControllerAuthorize = Authorize | Record<string, Authorize>
| function | Called with `(ctx, member)`; its result is checked like a setting. Returning `undefined` denies, returning `null` authorizes like unset. |

Strings and arrays deny requests without an authenticated user
(`ctx.state.user`, set by the application's Passport middleware).
(`ctx.state.user`, see [`UserMixin`](./model.md#sessions)).

`authorize` can be set in three places, from strongest to weakest:

Expand Down
2 changes: 1 addition & 1 deletion docs/api/server/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ explains the concepts; these pages list the members.
| Export | Description |
| --------------------------------------------------------------- | ---------------------------------------------- |
| [`TimestampedMixin`, `TimestampedModel`](model.md#timestampedmixin) | `createdAt` and `updatedAt`. |
| [`UserMixin`, `UserModel`](model.md#usermixin) | Users with Passport authentication. |
| [`UserMixin`, `UserModel`](model.md#usermixin) | Users that log in with a password. |
| [`SessionMixin`, `SessionModel`](model.md#sessionmixin) | Session storage. |
| [`AssetMixin`, `AssetModel`](model.md#assetmixin) | Asset records. |

Expand Down
39 changes: 14 additions & 25 deletions docs/api/server/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -585,7 +585,7 @@ static setup()

Called once by [`app.setupModels()`](application.md#setupmodels), before
`initialize()`. Internal: dito's mixins override it, e.g.
[`UserMixin`](#usermixin) registers its Passport strategy here, and call
[`UserMixin`](#usermixin) defines its password property here, and call
`super.setup()`. Apps override [`initialize()`](#static-initialize)
instead, which needs no `super`.

Expand Down Expand Up @@ -935,9 +935,7 @@ export class User extends UserMixin(Model) {
}
```

Users that log in with a username and password, through
[Passport](https://www.passportjs.org/)'s local strategy. Enable
`app.passport` (and usually `app.session`) in the
Users that log in with a username and password. Enable `app.session` in the
[configuration](application.md#configapp), and serve the model through a
[`UsersController`](controllers.md#userscontroller) for the `login`,
`logout`, `session` and `self` actions.
Expand Down Expand Up @@ -967,30 +965,20 @@ Set as `static options` on the model:
const user = await User.login(ctx, options?)
```

Authenticates the request's credentials with the model's Passport strategy and
logs the user in with `ctx.login(user, options)`. Resolves to the user. Rejects
with an [`AuthenticationError`](errors.md#authenticationerror): status 401 with
"Password or username is incorrect" for wrong credentials, or the status and
message of the strategy, e.g. 400 "Missing credentials".

#### `static` sessionQuery()

```js
const query = User.sessionQuery(trx?)
```

The query that loads users for logins and sessions, with the `sessionScope`
scopes applied. Override it to load users differently. *Hook.* Returns
[`QueryBuilder`](query-builder.md).
Authenticates the credentials in the request body, under `usernameProperty` and
`passwordProperty`, and logs the user in with `ctx.login(user, options)`.
Resolves to the user. Rejects with an
[`AuthenticationError`](errors.md#authenticationerror): status 400 with "Missing
credentials", or 401 with "Password or username is incorrect".

#### `static` setup()

```js
static setup()
```

Registers the Passport strategy of the model under its class name, and the
accessor of the password property. Call `super.setup()` when overriding it.
Defines the accessor of the password property. Call `super.setup()` when
overriding it.

#### $verifyPassword()

Expand Down Expand Up @@ -1043,8 +1031,10 @@ Returns `boolean`: whether this user is the logged in user of the request,

Logged in users are stored in the session as `<ModelName>-<id>`, so several
user model classes can be used side by side. When a session is loaded, the
user is fetched with `sessionQuery()`; users that no longer exist, or that the
session scope excludes, end the session.
user is fetched with the `sessionScope` and set as `ctx.state.user`; users that
no longer exist, or that the session scope excludes, end the session.
`ctx.login(user, { session: false })` logs a user in for the request only, and
`ctx.isAuthenticated()` tells whether the request has a user.

#### Login events

Expand Down Expand Up @@ -1076,8 +1066,7 @@ const app = new Application({
config: {
app: {
keys: ['…'],
session: { modelClass: 'Session' },
passport: true
session: { modelClass: 'Session' }
}
},
models: { User, Session }
Expand Down
3 changes: 1 addition & 2 deletions docs/guide/server/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ export default {
normalizePaths: true,
proxy: true,
session: { modelClass: 'Session' },
passport: true,
keys: [env.APP_KEY]
},

Expand Down Expand Up @@ -101,7 +100,7 @@ export default {
- `keys`: the secret keys for signed cookies. They also sign uploaded
files, see [Storages](storages.md#signatures). Dito.js removes them from
`app.config`.
- `session` and `passport`: needed for users who log in, see
- `session`: needed for users who log in, see
[Controllers: users and sessions](controllers.md#users-and-sessions).

The middleware that is on by default (`helmet`, `cors`, `compress`, `etag`,
Expand Down
6 changes: 2 additions & 4 deletions docs/guide/server/controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -520,17 +520,15 @@ every exported controller is registered and routed.

## Users and sessions

Logging in needs sessions, Passport, a user model and a users controller.
Logging in needs sessions, a user model and a users controller.

The configuration turns on sessions and Passport, and the keys that sign the
session cookie:
The configuration turns on sessions, and the keys that sign the session cookie:

```js
// config/index.js
export default {
app: {
session: { modelClass: 'Session' },
passport: true,
keys: [process.env.APP_KEY]
}
}
Expand Down
2 changes: 0 additions & 2 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@
"koa-conditional-get": "^3.0.0",
"koa-helmet": "^9.0.0",
"koa-mount": "^4.2.0",
"koa-passport": "^6.0.0",
"koa-response-time": "^2.1.0",
"koa-session": "^7.0.2",
"koa-static": "^5.0.0",
Expand All @@ -63,7 +62,6 @@
"multer-s3": "github:ditojs/multer-s3#dito",
"nanoid": "^6.0.2",
"parse-duration": "^2.1.9",
"passport-local": "^1.0.0",
"passthrough-counter": "^1.0.0",
"picocolors": "^1.1.1",
"picomatch": "^4.0.7",
Expand Down
13 changes: 3 additions & 10 deletions packages/server/src/app/Application.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import compose from 'koa-compose'
import compress from 'koa-compress'
import conditional from 'koa-conditional-get'
import mount from 'koa-mount'
import passport from 'koa-passport'
import helmet from 'koa-helmet'
import responseTime from 'koa-response-time'
import { Model, knexSnakeCaseMappers, ref } from 'objection'
Expand Down Expand Up @@ -672,15 +671,9 @@ export class Application extends Koa {
if (app.session) {
this.use(handleSession(this, getOptions(app.session)))
}
// 6. passport
if (app.passport) {
this.use(passport.initialize())
if (app.session) {
this.use(passport.session())
}
this.use(handleUser())
}
// 6. finally handle the found route, or set status / allow accordingly.
// 6. users
this.use(handleUser())
// 7. finally handle the found route, or set status / allow accordingly.
this.use(handleRoute())
}

Expand Down
4 changes: 2 additions & 2 deletions packages/server/src/app/Application.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -889,8 +889,8 @@ describe('Application', () => {
})
})

it('handles users with passport, also without sessions', async () => {
const app = createKitchenApp({ passport: true })
it('handles users, also without sessions', async () => {
const app = createKitchenApp()
await withServer(app, async url => {
const response = await fetch(`${url}/kitchen/login`)
expect(await response.json()).toEqual({ hasLogin: true })
Expand Down
8 changes: 6 additions & 2 deletions packages/server/src/controllers/UsersController.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,11 @@ export class UsersController extends ModelController {
}

isAuthenticated(ctx) {
// Make sure the currently logged in user has the correct model-class:
return ctx.isAuthenticated() && ctx.state.user instanceof this.modelClass
// Make sure the currently logged in user has the exact model class, since
// sub-classes have their own tables and ids:
return (
ctx.isAuthenticated() &&
ctx.state.user.constructor === this.modelClass
)
}
}
71 changes: 57 additions & 14 deletions packages/server/src/middleware/handleUser.js
Original file line number Diff line number Diff line change
@@ -1,31 +1,74 @@
export function handleUser() {
return (ctx, next) => {
return async (ctx, next) => {
// The session key `passport` stems from koa-passport. We keep it so that
// existing sessions remain valid.
const identifier = ctx.session?.passport?.user
if (identifier) {
const { modelName, id } = parseUserIdentifier(identifier)
const userClass = ctx.app.models[modelName]
const user = userClass?.sessionQuery
? await userClass.sessionQuery(ctx.transaction).findById(id)
: null
if (user) {
ctx.state.user = user
} else {
// End the session of users that no longer exist or are excluded by the
// session scope.
delete ctx.session.passport.user
}
}

// Attach a logger instance with the user to the context.
ctx.logger = ctx.logger?.child({ user: ctx.state.user }) || null

// Override `ctx.login()` and `ctx.logout()` with versions that emit events
// on the user model:
const { login, logout } = ctx

ctx.login = ctx.logIn = async function (user, options = {}) {
await user.$emit('before:login', options)
await login.call(this, user, options)
await user.$emit('after:login', options)
ctx.login = ctx.logIn = async (user, options = {}) => {
const { session } = ctx
const { name, sessionQuery } = user.constructor
const shouldUseSession = options.session !== false
// Check before emitting events, for logins that fail anyway:
if (shouldUseSession && !session) {
throw new Error(`Unable to log in without sessions, see 'app.session'.`)
} else if (shouldUseSession && !sessionQuery) {
throw new Error(`Unable to log in '${name}', which isn't a user.`)
} else {
await user.$emit('before:login', options)
if (shouldUseSession) {
session.passport = { user: getUserIdentifier(user) }
// Regenerate the session id against session fixation, which also
// saves the session.
await session.regenerate()
}
ctx.state.user = user
await user.$emit('after:login', options)
}
}

ctx.logout = ctx.logOut = async function (options = {}) {
ctx.logout = ctx.logOut = async (options = {}) => {
const { user } = ctx.state
await user?.$emit('before:logout', options)
await logout.call(this, options)
// Clear the session after logout, apparently koa-passport doesn't take
// care of this itself:
// https://stackoverflow.com/questions/55818887/koa-passport-logout-is-not-clearing-session
ctx.state.user = null
// Clear the whole session after logout, not just the user:
ctx.session = null
await user?.$emit('after:logout', options)
}

ctx.isAuthenticated = () => !!ctx.state.user
ctx.isUnauthenticated = () => !ctx.state.user

return next()
// Don't set back `ctx.logger` because the context is still valid. It is
// used in `logResponse()` of the `logRequests()` middleware, among others.
}
}

// To support multiple user model classes, sessions identify users by both their
// model class name and id:
export function getUserIdentifier(user) {
return `${user.constructor.name}-${user.$id()}`
}

export function parseUserIdentifier(identifier) {
// Only split at the first hyphen, since ids may contain hyphens (UUIDs):
const [, modelName, id] = identifier.match(/^([^-]+)-(.+)$/) ?? []
return { modelName, id }
}
Loading
Loading