Files
planka/server/api/controllers/access-tokens/create.js
T
Daniel Hiller de4d768831 fix: close the gaps a security review found in the default install
Reported against 2.2.1 by someone reading the source. Every finding held.

**Sign-in had no ceiling.** Failures were logged with the caller's address
and nothing more. Two counters now — one per address, one per account —
because the two attacks look different: one source working through many
accounts is caught by the first, many sources working on one account by the
second, and behind a proxy only the second still means anything.

The count is kept in the process that serves the request. PLANKA needs no
Redis and the stock deployment is one container; run several and each keeps
its own count, which multiplies the ceiling by their number. That trade is
written where the limits are configured.

**The second factor could be guessed at leisure.** Six digits, and a
pending token that stayed valid for its full ten minutes however many codes
were wrong. Wrong codes are now counted on the session row — in the
database, so the count survives a restart and holds across every process —
and when the budget is spent the session is destroyed. After that even the
right code is refused and the login starts over from the password.

**Avatars, background images and favicons** checked the token's signature
and nothing else, so a revoked session, a deactivated account or a changed
password all kept working there for as long as the signature lasted, which
is a year by default. The five checks the API makes now live in one helper
that both use, rather than the shortened copy that had drifted from it.

**A link attachment's favicon** was fetched from wherever the URL pointed.
Storing a link is harmless — it is a string the user typed — but fetching
its icon is a request the server makes to an address the user chose, and
whether an icon came back reported on what is reachable from inside the
network. Server-side fetches now refuse private, loopback and link-local
addresses, `169.254.169.254` among them. The attachment is still created:
linking to an internal wiki is a legitimate thing to do, and it was the
server's own request that had to stop.

**The signing key.** Our own compose file ships `notsecretkey`, and it is
printed in the documentation — so on any instance that copied it, anyone can
sign a token for any account. PLANKA now says so on every start, and keeps
saying it, along with a key that is missing or shorter than 32 characters.
The placeholder carries the warning inline, where it is copied from.

**The backup script** wrote password hashes, live sessions, TOTP secrets and
SMTP credentials to an unencrypted archive. `BACKUP_PASSPHRASE` now encrypts
it, and without one the script says what it just put on disk. It also says
what it is — an example for the stock compose stack, not a backup concept —
and names the window between the database dump and the file copy, which no
ordering closes.
2026-08-28 20:55:54 +02:00

246 lines
7.5 KiB
JavaScript
Executable File

/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
/**
* @swagger
* /access-tokens:
* post:
* summary: User login
* description: Authenticates a user using email/username and password. Returns an access token for API authentication.
* tags:
* - Access Tokens
* operationId: createAccessToken
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - emailOrUsername
* - password
* properties:
* emailOrUsername:
* type: string
* maxLength: 256
* description: Email address or username of the user
* example: john.doe@example.com
* password:
* type: string
* maxLength: 256
* description: Password of the user
* example: SecurePassword123!
* withHttpOnlyToken:
* type: boolean
* description: Whether to include an HTTP-only authentication cookie
* example: true
* responses:
* 200:
* description: Login successful
* content:
* application/json:
* schema:
* type: object
* required:
* - item
* properties:
* item:
* type: string
* description: Access token for API authentication
* example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ4...
* headers:
* Set-Cookie:
* description: HTTP-only authentication cookie (if `withHttpOnlyToken` is true)
* schema:
* type: string
* example: httpOnlyToken=29aa3e38-8d24-4029-9743-9cbcf0a0dd5c; HttpOnly; Secure; SameSite=Strict
* 400:
* $ref: '#/components/responses/ValidationError'
* 401:
* description: Invalid credentials
* content:
* application/json:
* schema:
* type: object
* required:
* - code
* - message
* properties:
* code:
* type: string
* description: Error code
* example: E_UNAUTHORIZED
* message:
* type: string
* enum:
* - Invalid credentials
* - Invalid email or username
* - Invalid password
* description: Specific error message
* example: Invalid credentials
* 403:
* description: Authentication restriction
* content:
* application/json:
* schema:
* type: object
* required:
* - code
* - message
* properties:
* code:
* type: string
* description: Error code
* example: E_FORBIDDEN
* message:
* type: string
* enum:
* - Terms acceptance required
* - TOTP verification required
* - Admin login required to initialize instance
* description: Specific error message
* example: Terms acceptance required
* security: []
*/
const bcrypt = require('bcrypt');
const { isEmailOrUsername } = require('../../../utils/validators');
const { getRemoteAddress } = require('../../../utils/remote-address');
const Errors = {
INVALID_CREDENTIALS: {
invalidCredentials: 'Invalid credentials',
},
INVALID_EMAIL_OR_USERNAME: {
invalidEmailOrUsername: 'Invalid email or username',
},
INVALID_PASSWORD: {
invalidPassword: 'Invalid password',
},
TERMS_ACCEPTANCE_REQUIRED: {
termsAcceptanceRequired: 'Terms acceptance required',
},
RATE_LIMIT_EXCEEDED: {
rateLimitExceeded: 'Rate limit exceeded',
},
};
module.exports = {
inputs: {
emailOrUsername: {
type: 'string',
maxLength: 256,
custom: isEmailOrUsername,
required: true,
},
password: {
type: 'string',
maxLength: 256,
required: true,
},
withHttpOnlyToken: {
type: 'boolean',
},
},
exits: {
invalidCredentials: {
responseType: 'unauthorized',
},
invalidEmailOrUsername: {
responseType: 'unauthorized',
},
invalidPassword: {
responseType: 'unauthorized',
},
rateLimitExceeded: {
responseType: 'conflict',
},
termsAcceptanceRequired: {
responseType: 'forbidden',
},
totpVerificationRequired: {
responseType: 'forbidden',
},
adminLoginRequiredToInitializeInstance: {
responseType: 'forbidden',
},
},
async fn(inputs) {
const remoteAddress = getRemoteAddress(this.req);
// Counted before the lookup, so a script cannot make the database do the
// work of telling it that an account does not exist. Two counters: one
// source against many accounts is caught per address, many sources against
// one account per identifier — and behind a proxy only the second still
// means anything, which is why both are here.
const identifier = inputs.emailOrUsername.trim().toLowerCase();
// eslint-disable-next-line no-restricted-syntax
for (const [key, max] of [
[`auth:ip:${remoteAddress}`, sails.config.custom.authRateLimitMaxPerIp],
[
// Hashed so the key that lives in memory for the window is not the
// address itself.
`auth:identifier:${sails.helpers.utils.hash(identifier)}`,
sails.config.custom.authRateLimitMaxPerIdentifier,
],
]) {
const { isExceeded } = sails.helpers.utils.checkRateLimit.with({
key,
windowSeconds: sails.config.custom.authRateLimitWindow,
max,
});
if (isExceeded) {
sails.log.warn(`Login rate limit hit (IP: ${remoteAddress})`);
throw Errors.RATE_LIMIT_EXCEEDED;
}
}
const user = await User.qm.getOneActiveByEmailOrUsername(inputs.emailOrUsername);
if (!user) {
sails.log.warn(
`Invalid email or username: "${inputs.emailOrUsername}"! (IP: ${remoteAddress})`,
);
throw sails.config.custom.showDetailedAuthErrors
? Errors.INVALID_EMAIL_OR_USERNAME
: Errors.INVALID_CREDENTIALS;
}
const isPasswordValid = await bcrypt.compare(inputs.password, user.password);
if (!isPasswordValid) {
sails.log.warn(`Invalid password! (IP: ${remoteAddress})`);
throw sails.config.custom.showDetailedAuthErrors
? Errors.INVALID_PASSWORD
: Errors.INVALID_CREDENTIALS;
}
return sails.helpers.accessTokens.handleSteps
.with({
user,
remoteAddress,
request: this.req,
response: this.res,
withHttpOnlyToken: inputs.withHttpOnlyToken,
})
.intercept('adminLoginRequiredToInitializeInstance', (error) => ({
adminLoginRequiredToInitializeInstance: error.raw,
}))
.intercept('termsAcceptanceRequired', (error) => ({
termsAcceptanceRequired: error.raw,
}))
.intercept('totpVerificationRequired', (error) => ({
totpVerificationRequired: error.raw,
}));
},
};