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.
246 lines
7.5 KiB
JavaScript
Executable File
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,
|
|
}));
|
|
},
|
|
};
|