3 const common
= require('./common');
4 const Enum
= require('./enum');
5 const Errors
= require('./errors');
6 const { MysteryBox
} = require('@squeep/mystery-box');
8 const _fileScope
= common
.fileScope(__filename
);
12 * @param {Console} logger
14 * @param {Object} options
15 * @param {Object} options.authenticator
16 * @param {String} options.authenticator.basicRealm
17 * @param {Boolean} options.authenticator.secureAuthOnly
18 * @param {String[]} options.authenticator.forbiddenPAMIdentifiers
19 * @param {String[]} options.authenticator.authnEnabled
21 constructor(logger
, db
, options
) {
24 this.basicRealm
= options
.authenticator
.basicRealm
;
25 this.secureAuthOnly
= options
.authenticator
.secureAuthOnly
;
31 this.authn
.argon2
= require('argon2');
34 this.authn
.pam
= require('node-linux-pam');
35 this.forbiddenPAMIdentifiers
= options
.authenticator
.forbiddenPAMIdentifiers
;
38 this.authnEnabled
= Object
.keys(this.authn
).filter((auth
) => options
.authenticator
.authnEnabled
.includes(auth
));
39 this.logger
.debug(_fileScope('constructor'), 'available mechanisms', { authn: this.authnEnabled
});
41 if (this.authnEnabled
.length
=== 0) {
42 throw new Error('no authentication mechanisms available');
45 this.mysteryBox
= new MysteryBox(logger
, options
);
50 * Check for valid Basic auth, updates ctx with identifier if valid.
51 * @param {String} credentials
55 async
isValidBasic(credentials
, ctx
) {
56 const _scope
= _fileScope('isValidBasic');
57 this.logger
.debug(_scope
, 'called', { ctx
});
59 const [identifier
, credential
] = common
.splitFirst(credentials
, ':', '');
61 return this.isValidIdentifierCredential(identifier
, credential
, ctx
);
66 * Check local auth entries.
67 * @param {String} identifier
68 * @param {String} credential
71 async
isValidIdentifierCredential(identifier
, credential
, ctx
) {
72 const _scope
= _fileScope('isValidIdentifierCredential');
73 this.logger
.debug(_scope
, 'called', { identifier
, credential: '*'.repeat(credential
.length
), ctx
});
77 await
this.db
.context(async (dbCtx
) => {
78 const authData
= await
this.db
.authenticationGet(dbCtx
, identifier
);
80 this.logger
.debug(_scope
, 'failed, invalid identifier', { ctx
, identifier
});
82 if (authData
.credential
.startsWith('$argon2')
83 && this.authnEnabled
.includes('argon2')) {
84 isValid
= await
this.authn
.argon2
.verify(authData
.credential
, credential
);
85 } else if (authData
.credential
.startsWith('$PAM$')
86 && this.authnEnabled
.includes('pam')) {
87 isValid
= this._isValidPAMIdentifier(identifier
, credential
);
89 this.logger
.error(_scope
, 'failed, unknown type of stored credential', { identifier
, ctx
});
93 if (this.authnEnabled
.includes('DEBUG_ANY')) {
98 ctx
.authenticationId
= identifier
;
99 await
this.db
.authenticationSuccess(dbCtx
, identifier
);
109 * @param {String} identifier
110 * @param {String} credential
113 async
_isValidPAMIdentifier(identifier
, credential
) {
114 const _scope
= _fileScope('_isValidPAMIdentifier');
116 if (this.forbiddenPAMIdentifiers
.includes(identifier
)) {
120 await
this.authn
.pam
.pamAuthenticatePromise({ username: identifier
, password: credential
});
123 this.logger
.debug(_scope
, 'failed', { error: e
});
124 if (!(e
instanceof this.authn
.pam
.PamError
)) {
133 * Determine which Authorization header is available, and if it is valid.
134 * @param {String} authorizationHeader
135 * @param {Object} ctx
137 async
isValidAuthorization(authorizationHeader
, ctx
) {
138 const _scope
= _fileScope('isValidAuthorization');
139 this.logger
.debug(_scope
, 'called', { authorizationHeader
, ctx
});
141 const [authMethod
, authString
] = common
.splitFirst(authorizationHeader
, ' ', '').map((x
) => x
.trim());
142 // eslint-disable-next-line sonarjs/no-small-switch
143 switch (authMethod
.toLowerCase()) {
145 const credentials
= Buffer
.from(authString
, 'base64').toString('utf-8');
146 return this.isValidBasic(credentials
, ctx
);
150 this.logger
.debug(_scope
, 'unknown authorization scheme', { ctx
});
157 * Send a response requesting basic auth.
158 * @param {http.ServerResponse} res
161 res
.setHeader(Enum
.Header
.WWWAuthenticate
, `Basic realm="${this.basicRealm}", charset="UTF-8"`);
162 throw new Errors
.ResponseError(Enum
.ErrorResponse
.Unauthorized
);
167 * Attempt to parse a session cookie, and determine if it
168 * contains authenticated user.
169 * Restores ctx.session from cookie data.
170 * @param {Object} ctx
171 * @param {String} cookieHeader
174 async
isValidCookieAuth(ctx
, cookieHeader
) {
175 const _scope
= _fileScope('isValidCookieAuth');
176 this.logger
.debug(_scope
, 'called', { ctx
, cookieHeader
});
178 const [ cookieName
, cookieValue
] = common
.splitFirst(cookieHeader
, '=', '');
179 if (cookieName
!== 'WSHas') {
183 ctx
.session
= await
this.mysteryBox
.unpack(cookieValue
);
184 this.logger
.debug(_scope
, 'unpacked cookie', { ctx
});
185 return !!ctx
.session
.authenticatedProfile
|| !! ctx
.session
.authenticatedIdentifier
;
187 this.logger
.debug(_scope
, 'could not unpack cookie', { error:e
, ctx
});
194 * Require that a request has valid auth over secure channel, requests if missing.
195 * @param {http.ClientRequest} req
196 * @param {http.ServerResponse} res
197 * @param {Object} ctx
198 * @param {String} loginPath
200 async
required(req
, res
, ctx
, loginPath
) {
201 const _scope
= _fileScope('required');
202 this.logger
.debug(_scope
, 'called', { ctx
});
204 if (this.secureAuthOnly
&& ctx
.clientProtocol
.toLowerCase() !== 'https') {
205 this.logger
.debug(_scope
, 'rejecting insecure auth', ctx
);
206 throw new Errors
.ResponseError(Enum
.ErrorResponse
.Forbidden
, 'authentication required, but connection is insecure; cannot continue');
209 const sessionCookie
= req
.getHeader(Enum
.Header
.Cookie
);
210 if (sessionCookie
&& await
this.isValidCookieAuth(ctx
, sessionCookie
)) {
214 const authData
= req
.getHeader(Enum
.Header
.Authorization
);
216 if (await
this.isValidAuthorization(authData
, ctx
)) {
219 // If they came in trying header auth, let them try again.
220 return this.requestBasic(res
);
223 // Otherwise redirect to login.
224 res
.statusCode
= 302;
225 res
.setHeader(Enum
.Header
.Location
, loginPath
);
233 * Require that a request has valid local auth over secure channel, requests if missing.
234 * @param {http.ClientRequest} req
235 * @param {http.ServerResponse} res
236 * @param {Object} ctx
237 * @param {String} loginPath
239 async
requiredLocal(req
, res
, ctx
, loginPath
) {
240 const _scope
= _fileScope('requiredLocal');
241 this.logger
.debug(_scope
, 'called', { ctx
});
243 if (this.secureAuthOnly
&& ctx
.clientProtocol
.toLowerCase() !== 'https') {
244 this.logger
.debug(_scope
, 'rejecting insecure auth', ctx
);
245 throw new Errors
.ResponseError(Enum
.ErrorResponse
.Forbidden
, 'authentication required, but connection is insecure; cannot continue');
248 // Only accept identifier sessions.
249 const sessionCookie
= req
.getHeader(Enum
.Header
.Cookie
);
251 && await
this.isValidCookieAuth(ctx
, sessionCookie
)
252 && ctx
.session
.authenticatedIdentifier
) {
257 const authData
= req
.getHeader(Enum
.Header
.Authorization
);
259 if (await
this.isValidAuthorization(authData
, ctx
)) {
262 // If they came in trying header auth, let them try again.
263 return this.requestBasic(res
);
266 // Otherwise redirect to login.
267 res
.statusCode
= 302;
268 res
.setHeader(Enum
.Header
.Location
, loginPath
);
276 module
.exports
= Authenticator
;