Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HTTP_BASIC_AUTH_HANDLER #70

Closed
wants to merge 14 commits into from
Closed

HTTP_BASIC_AUTH_HANDLER #70

wants to merge 14 commits into from

Conversation

zawapete
Copy link
Contributor

@zawapete zawapete commented Oct 31, 2020

Add method httpbasic to auth

Description

The feautre is enabled in case the auth cli arg is set to httpbasic
In such case jwtFromRequest in passport.ts will extract the username from the http basic credentials and compare with the one from the jwt token. if there is a match the process continues as before
The /api/auth/authenticate will read the credentials from http basic auth instead of body request and continue with the same flow.
The /api/auth/logout will send a 401 Unathorized response.

Related Issue

Motivation and Context

I use flood behind a nginx proxy with http basic auth and I want to use this credentials for logging in in flood for a smoother experience.

How Has This Been Tested?

Tested on a server with multiple users (admin and not) with flood setup behind nginx proxy

Screenshots (if appropriate):

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist:

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.

@codecov
Copy link

codecov bot commented Oct 31, 2020

Codecov Report

Merging #70 (6523ba7) into master (88b284b) will decrease coverage by 4.75%.
The diff coverage is 90.96%.

❗ Current head 6523ba7 differs from pull request most recent head a247ffd. Consider uploading reports for the commit a247ffd to get more accurate results
Impacted file tree graph

@@            Coverage Diff             @@
##           master      #70      +/-   ##
==========================================
- Coverage   78.51%   73.76%   -4.76%     
==========================================
  Files          57       52       -5     
  Lines        9226     7029    -2197     
  Branches      908      516     -392     
==========================================
- Hits         7244     5185    -2059     
+ Misses       1982     1844     -138     
Impacted Files Coverage Δ
server/config/passport.ts 78.57% <67.64%> (-6.80%) ⬇️
server/util/authUtil.ts 95.34% <95.34%> (+24.23%) ⬆️
server/routes/api/auth.ts 92.90% <98.71%> (-0.30%) ⬇️
...erver/services/qBittorrent/clientGatewayService.ts 10.77% <0.00%> (-63.28%) ⬇️
...services/qBittorrent/util/torrentPropertiesUtil.ts 13.97% <0.00%> (-46.24%) ⬇️
...erver/services/qBittorrent/clientRequestManager.ts 24.07% <0.00%> (-41.88%) ⬇️
server/util/fileUtil.ts 53.70% <0.00%> (-28.12%) ⬇️
server/models/HistoryEra.ts 75.00% <0.00%> (-20.91%) ⬇️
server/services/rTorrent/clientGatewayService.ts 55.98% <0.00%> (-14.53%) ⬇️
server/routes/api/client.ts 88.46% <0.00%> (-7.79%) ⬇️
... and 45 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 88b284b...a247ffd. Read the comment docs.

Copy link
Owner

@jesec jesec left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you provide more details on how would a user register initially and subsequently? The users are authenticated by the web server and then upon receiving an authenticated request how would the Flood onboard the user in its database? What would be the frontend user experience for this?

config.cli.js Outdated
@@ -36,7 +36,7 @@ const {argv} = require('yargs')
})
.option('auth', {
describe: 'Access control and user management method',
choices: ['default', 'none'],
choices: ['default', 'httpbasic', 'none'],
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

header would be a better choice.

Copy link
Contributor Author

@zawapete zawapete Nov 1, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

header would be ambigous: what if tomorrow we add http digest, or any other bearer token?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With logics of this PR, any header authentication method shall only take the username. So if digest method needs to be supported, we simply parse the username from that type of header without changing other logics. Headers which don't supply username such as Bearer can't be supported.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the logic is executed only when type httpbasic, and in case of digest the authorization header will be parsed differently also.

Bearer could provide the username in the payload, and we will have a method bearerSomething and add a branch in the switch.

or the bearer could provided metadata needed to extract the username for in other way and finally compare with the jwt token username, adding extra logic.

that's why reducing everything to header method it is not the correct solution in my opinion.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For all other types which username can be parsed, the authentication and user management logics are the same. The only difference is how to parse the username which can be handled by a simple switch case. I prefer that we use header to be more general.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only difference is how to parse the username which can be handled by a simple switch case. I prefer that we use header to be more general.

that's what currently happens: a switch case on the auth value for different logic how to parse the username.
if we have a generic header value the switch should need extra information (we could have a headertype settings, it won't change much indeed). if we don't have this extra information we have to inspect the header and apply the parse method that will apply. it would be nice, but I doubt that there could be the cases where the same header inspection won't give enough information on how to actually parse it: then I don't see any other solution than applying multiple ambigous parsing until one won't succed.

what do you think?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now, we can enforce the Basic type.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so auth value header that will assume only Basic type?
then it's only a matter of replacing httpbasic with header

I'm still not convinved this is the best way to easy future support to different methods, but as you prefer

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. It would be very straightforward to add support for other auth header types in the future. After all, we only want the username.

}

credentials = parsedResult.data;
token = getAuthToken(credentials.username);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. We shall not unconditionally bypass the token in cookie validation here. We need cookie to utilize browser's protections against session hijacking.

For example, if there is no protection against session hijacking, an attacker can POST something like {"urls":["https://example.virus/malicious.torrent"],"destination":"/your/most/important/folder"} to API endpoint /api/torrents/add-urls of your Flood instance FROM their own site while utilizing your (already authenticated) credentials.

HTTP basic auth and disabled auth are especially vulnerable to this type of attacks. Browser always include basic auth header when user is authenticated no matter where the request comes from. auth=none sent cookie with token unconditionally when /authenticate or /verify is accessed. You may send cookie according to your design in /authenticate and /verify endpoints.

More info about the session hijacking:
https://security.stackexchange.com/questions/234341/http-basic-auth-and-csrf

Copy link
Contributor Author

@zawapete zawapete Nov 1, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to rely only on the http basic credentials, but actually I tried another implementation before this final:

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no need to modify this file. You only need to add logics to /verify and /authenticate endpoints. Simply parse the header to get the username and then reply with a generated token.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and then for all the other endpoints you could send different credentials between jwt cookie and http basic.
that's really a mess, in my opinion.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other endpoints simply don't handle header. They only validate the cookie. Only selected endpoints handle alternative auth methods. This allows uniformity in auth enforcement and prevents errors in authentication system which can be disastrous.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sure this added logic is an added step. Don't replace other logics for simplicity and the peace of mind that this won't hurt security.

Copy link
Contributor Author

@zawapete zawapete Nov 1, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is indeed a possibility of inconsistent header/cookie authentication state.
indeed: imagine the following setup (all behind a nginx proxy that forward to related upstream and different httpasswd by path):

I login to the first as aFloodUser
I open a new tab for the second with guest login
I would not receive a 401 from /flood but still the username will differ from the one in the jwt token.
My changes will force a 401 from flood

Makes sure this added logic is an added step. Don't replace other logics for simplicity and the peace of mind that this won't hurt security.

sure, that's the whole point: inject new logic only in the switch branch for httpbasic. please validate during review that I didn't change the current behaviour in different/default branches

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great. The logics look OK to me. However, the organization of functions needs some improvements in my opinion. May I push directly to the PR to tweak it a bit?

Copy link
Contributor Author

@zawapete zawapete Nov 1, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great. let me push the last changes with frontend handling the prefilled registration and then feel free to push any tweak to the PR.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I posted the patch: #70 (comment). You can apply it by yourself. Or would you want me to apply it?

* @return {} 200 - success response
*/
router.get('/logout', (_req, res) => {
res.clearCookie('jwt').send();
switch (preloadConfigs.authMethod) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can make the response 401 for all auth methods.

Copy link
Contributor Author

@zawapete zawapete Nov 1, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't want to change the API, much better returning 401 for everyone

@@ -5,10 +5,32 @@ import {credentialsSchema} from '../Auth';

import type {AuthMethod} from '../Auth';

export const httpBasicAuth = (authorization: string) => {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No function in this file. The file is for schema only.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will move to util/authUtil.ts

// All auth requests are schema validated to ensure security.

// POST /api/auth/authenticate
export const authAuthenticationSchema = credentialsSchema.pick({username: true, password: true});
export const authHTTPBasicAuthenticationSchema = (authorization?: string) => {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will move to util/authUtil.ts

@@ -103,7 +89,15 @@ router.post<unknown, unknown, AuthAuthenticationOptions>('/authenticate', (req,
return;
}

const parsedResult = authAuthenticationSchema.safeParse(req.body);
let parsedResult = authAuthenticationSchema.safeParse(null);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to safe parse and then go through the password authentication procedures and there is no need for schema validation for safe inputs as incoming requests are already authenticated by the web server.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still need to compare password. I agree that I don't need to authAuthenticationSchema.safeParse(null) but then I'm not able to understand what type I should declare parsedResult

@zawapete
Copy link
Contributor Author

zawapete commented Nov 1, 2020

Can you provide more details on how would a user register initially and subsequently? The users are authenticated by the web server and then upon receiving an authenticated request how would the Flood onboard the user in its database? What would be the frontend user experience for this?

if no user is registered yet after inserting credentials for the http authentication the registration page would be shown in frontend. you can then register yourself with the same credentials as the http authentication. you can also register someone else but then you won't be able to login yourself. also if you register a user with a different password from the one in the http basic credentials you won't be able to login.

@jesec
Copy link
Owner

jesec commented Nov 1, 2020

Can you provide more details on how would a user register initially and subsequently? The users are authenticated by the web server and then upon receiving an authenticated request how would the Flood onboard the user in its database? What would be the frontend user experience for this?

if no user is registered yet after inserting credentials for the http authentication the registration page would be shown in frontend. you can then register yourself with the same credentials as the http authentication. you can also register someone else but then you won't be able to login yourself. also if you register a user with a different password from the one in the http basic credentials you won't be able to login.

Looks generally OK to me. However, I don't think the user should have to type the username and password again. Server can send the parsed username along with preload configs. Frontend will then greyed the username and password input out with the username input pre-filled with parsed username.

@zawapete
Copy link
Contributor Author

zawapete commented Nov 1, 2020

Server can send the parsed username along with preload configs. Frontend will then greyed the username and password input out with the username input pre-filled with parsed username.

oki for me, so we pre-fill pasrsed username, disable both username and password fields, and in the backend endpoint we read username and password from http basic credentials. is it confirmed?

@jesec
Copy link
Owner

jesec commented Nov 1, 2020

Server can send the parsed username along with preload configs. Frontend will then greyed the username and password input out with the username input pre-filled with parsed username.

oki for me, so we pre-fill pasrsed username, disable both username and password fields, and in the backend endpoint we read username and password from http basic credentials. is it confirmed?

That's right. A small difference: we don't read or store the password as we don't use it after all. Storing password unnecessarily can increase the data protection liabilities and nullify the benefits of header authentication method.

Frontend can fill the password box with bunch of **** to give the impression that there is a password. (user can't edit it anyways) while the backend can either store a consistent or randomly generated string to database.

@zawapete
Copy link
Contributor Author

zawapete commented Nov 1, 2020

That's right. A small difference: we don't read or store the password as we don't use it after all

sure, I will disabled both user and password but prefill only username from the data returned by the backend.

@jesec
Copy link
Owner

jesec commented Nov 1, 2020

I made a patch that hopefully makes the logics cleaner. Please apply if possible.
diff --git a/config.cli.js b/config.cli.js
index 9dcb4fb9..23159e19 100644
--- a/config.cli.js
+++ b/config.cli.js
@@ -36,7 +36,8 @@ const {argv} = require('yargs')
   })
   .option('auth', {
     describe: 'Access control and user management method',
-    choices: ['default', 'httpbasic', 'none'],
+    default: 'default',
+    choices: ['default', 'header', 'none'],
   })
   .option('noauth', {
     alias: 'n',
@@ -202,11 +203,9 @@ if (argv.rtsocket != null || argv.rthost != null) {
   };
 }
 
-let authMethod = 'default';
-if (argv.noauth || argv.auth === 'none') {
+let authMethod = argv.auth;
+if (argv.noauth) {
   authMethod = 'none';
-} else {
-  authMethod = argv.auth;
 }
 
 let allowedPaths = [];
diff --git a/jest.config.js b/jest.config.js
index 2beffb35..d45c494b 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -4,7 +4,7 @@ module.exports = {
   coverageProvider: 'v8',
   projects: [
     '<rootDir>/server/.jest/auth.config.js',
-    '<rootDir>/server/.jest/httpauth.config.js',
+    '<rootDir>/server/.jest/auth.header.config.js',
     '<rootDir>/server/.jest/rtorrent.config.js',
     // TODO: qBittorrent tests are disabled at the moment.
     // '<rootDir>/server/.jest/qbittorrent.config.js',
diff --git a/server/.jest/httpauth.config.js b/server/.jest/auth.header.config.js
similarity index 54%
rename from server/.jest/httpauth.config.js
rename to server/.jest/auth.header.config.js
index 28634b6e..541b1d8f 100644
--- a/server/.jest/httpauth.config.js
+++ b/server/.jest/auth.header.config.js
@@ -1,10 +1,10 @@
 module.exports = {
-  displayName: 'auth',
+  displayName: 'auth.header',
   preset: 'ts-jest/presets/js-with-babel',
   rootDir: './../',
   testEnvironment: 'node',
-  testMatch: ['<rootDir>/routes/api/httpauth.test.ts'],
-  setupFilesAfterEnv: ['<rootDir>/.jest/httpauth.setup.js'],
+  testMatch: ['<rootDir>/routes/api/auth.header.test.ts'],
+  setupFilesAfterEnv: ['<rootDir>/.jest/auth.header.setup.js'],
   globals: {
     'ts-jest': {
       isolatedModules: true,
diff --git a/server/.jest/httpauth.setup.js b/server/.jest/auth.header.setup.js
similarity index 92%
rename from server/.jest/httpauth.setup.js
rename to server/.jest/auth.header.setup.js
index 1f27cf81..cb87a672 100644
--- a/server/.jest/httpauth.setup.js
+++ b/server/.jest/auth.header.setup.js
@@ -7,7 +7,7 @@ const temporaryRuntimeDirectory = path.resolve(os.tmpdir(), `flood.test.${crypto
 
 process.argv = ['node', 'flood'];
 process.argv.push('--rundir', temporaryRuntimeDirectory);
-process.argv.push('--auth', 'httpbasic');
+process.argv.push('--auth', 'header');
 
 afterAll(() => {
   if (process.env.CI !== 'true') {
diff --git a/server/config/passport.ts b/server/config/passport.ts
index 11170f83..7bcc236b 100644
--- a/server/config/passport.ts
+++ b/server/config/passport.ts
@@ -2,19 +2,18 @@ import {Strategy, VerifiedCallback} from 'passport-jwt';
 
 import type {PassportStatic} from 'passport';
 import type {Request} from 'express';
-import {infer as zodInfer, ZodError} from 'zod';
 
 import config from '../../config';
-import Users from '../models/Users';
 import {Credentials} from '../../shared/schema/Auth';
-import {authAuthenticationSchema} from '../../shared/schema/api/auth';
-import {authHTTPBasicAuthenticationSchema} from '../util/authUtil';
+import {parseAuthorizationHeader} from '../util/authUtil';
+import Users from '../models/Users';
 
 // Setup work and export for the JWT passport strategy.
 export default (passport: PassportStatic) => {
   const options = {
     jwtFromRequest: (req: Request) => {
       let token = null;
+
       if (req && req.cookies) {
         token = req.cookies.jwt;
       }
@@ -27,39 +26,20 @@ export default (passport: PassportStatic) => {
 
   passport.use(
     new Strategy(options, (req: Request, jwtPayload: Pick<Credentials, 'username'>, callback: VerifiedCallback) => {
-      let parsedResult:
-        | {
-            success: true;
-            data: Required<zodInfer<typeof authAuthenticationSchema>>;
-          }
-        | {
-            success: false;
-            error: ZodError;
-          };
-
-      switch (config.authMethod) {
-        case 'httpbasic':
-          parsedResult = authHTTPBasicAuthenticationSchema(req.header('authorization'));
-          if (!parsedResult.success) {
-            callback(null, false);
-            return;
-          }
-
-          if (jwtPayload.username !== parsedResult.data.username) {
-            callback(null, false);
-            return;
-          }
-          break;
-        case 'default':
-        default:
-      }
-
       Users.lookupUser(jwtPayload.username, (err, user) => {
         if (err) {
           return callback(err, false);
         }
 
         if (user) {
+          if (config.authMethod === 'header') {
+            const {username} = parseAuthorizationHeader(req.headers.authorization) || {};
+
+            if (username !== user.username) {
+              return callback(null, false);
+            }
+          }
+
           return callback(null, user);
         }
 
diff --git a/server/routes/api/httpauth.test.ts b/server/routes/api/auth.header.test.ts
similarity index 99%
rename from server/routes/api/httpauth.test.ts
rename to server/routes/api/auth.header.test.ts
index 0e56c5c2..af09c68a 100644
--- a/server/routes/api/httpauth.test.ts
+++ b/server/routes/api/auth.header.test.ts
@@ -33,7 +33,7 @@ const testAdminHTTPBasicAuth = `Basic ${Buffer.from(`${testAdminUser.username}:$
   'base64',
 )}`;
 
-const testNotExistingHTTPBasicAuth = `Basic ${Buffer.from('notExstingUser:password').toString('base64')}`;
+const testNotExistingHTTPBasicAuth = `Basic ${Buffer.from('notExistingUser:password').toString('base64')}`;
 
 const testNonAdminUser = {
   username: crypto.randomBytes(8).toString('hex'),
diff --git a/server/routes/api/auth.test.ts b/server/routes/api/auth.test.ts
index e4959318..c42ab12b 100644
--- a/server/routes/api/auth.test.ts
+++ b/server/routes/api/auth.test.ts
@@ -267,18 +267,6 @@ describe('GET /api/auth/logout', () => {
         done();
       });
   });
-
-  it('Logouts without credential', (done) => {
-    request
-      .get('/api/auth/logout')
-      .send()
-      .set('Accept', 'application/json')
-      .expect(401)
-      .end((err, _res) => {
-        if (err) done(err);
-        done();
-      });
-  });
 });
 
 describe('POST /api/auth/authenticate', () => {
diff --git a/server/routes/api/auth.ts b/server/routes/api/auth.ts
index d9e4f4ab..976fbe86 100644
--- a/server/routes/api/auth.ts
+++ b/server/routes/api/auth.ts
@@ -4,11 +4,8 @@ import rateLimit from 'express-rate-limit';
 
 import type {Response} from 'express';
 
-import {infer as zodInfer, ZodError} from 'zod';
-
+import {AccessLevel} from '../../../shared/schema/constants/Auth';
 import ajaxUtil from '../../util/ajaxUtil';
-import {getAuthToken, authHTTPBasicAuthenticationSchema} from '../../util/authUtil';
-
 import {
   authAuthenticationSchema,
   authRegistrationSchema,
@@ -16,6 +13,7 @@ import {
   AuthVerificationPreloadConfigs,
 } from '../../../shared/schema/api/auth';
 import config from '../../../config';
+import {getAuthToken, parseAuthorizationHeader} from '../../util/authUtil';
 import requireAdmin from '../../middleware/requireAdmin';
 import services from '../../services';
 import Users from '../../models/Users';
@@ -90,25 +88,19 @@ router.post<unknown, unknown, AuthAuthenticationOptions>('/authenticate', (req,
     return;
   }
 
-  let parsedResult:
-    | {
-        success: true;
-        data: Required<zodInfer<typeof authAuthenticationSchema>>;
-      }
-    | {
-        success: false;
-        error: ZodError;
-      };
+  if (config.authMethod === 'header') {
+    const {username} = parseAuthorizationHeader(req.headers.authorization) || {};
+    if (username == null) {
+      res.json(403).send();
+      return;
+    }
 
-  switch (preloadConfigs.authMethod) {
-    case 'httpbasic':
-      parsedResult = authHTTPBasicAuthenticationSchema(req.header('authorization'));
-      break;
-    case 'default':
-    default:
-      parsedResult = authAuthenticationSchema.safeParse(req.body);
+    sendAuthenticationResponse(res, {username, level: AccessLevel.USER});
+    return;
   }
 
+  const parsedResult = authAuthenticationSchema.safeParse(req.body);
+
   if (!parsedResult.success) {
     validationError(res, parsedResult.error);
     return;
@@ -277,11 +269,10 @@ router.use('/', passport.authenticate('jwt', {session: false}));
  * @summary Clears the session cookie
  * @tags Auth
  * @security User
- * @return {string} 401 - not authenticated or token expired or auth is httpbasic
- * @return {} 200 - success response
+ * @return {string} 401 - success response
  */
 router.get('/logout', (_req, res) => {
-  res.clearCookie('jwt').status(401).json('Unauthorized').send();
+  res.clearCookie('jwt').status(401).json('Logged out').send();
 });
 
 // All subsequent routes need administrator access.
diff --git a/server/util/authUtil.ts b/server/util/authUtil.ts
index 05072cf7..01e68a9c 100644
--- a/server/util/authUtil.ts
+++ b/server/util/authUtil.ts
@@ -1,16 +1,31 @@
 import {Response} from 'express';
 import jwt from 'jsonwebtoken';
+
+import type {AuthorizationHeader} from '@shared/schema/Auth';
+
 import config from '../../config';
-import {authAuthenticationSchema} from '../../shared/schema/api/auth';
 
-export const httpBasicAuth = (authorization: string) => {
-  const base64 = authorization.replace(/basic /i, '');
-  const credentials = Buffer.from(base64, 'base64').toString().split(':');
-  if (credentials.length !== 2 || credentials[0].length === 0 || credentials[1].length === 0) {
+export const parseAuthorizationHeader = (header?: string): AuthorizationHeader | null => {
+  if (header == null) {
+    return null;
+  }
+
+  const [type, value] = header.split(' ');
+
+  if (type !== 'Basic') {
     return null;
   }
 
-  return credentials;
+  try {
+    const [username] = Buffer.from(value, 'base64').toString().split(':');
+
+    return {
+      type,
+      username,
+    };
+  } catch (e) {
+    return null;
+  }
 };
 
 export const getAuthToken = (username: string, res?: Response): string => {
@@ -28,16 +43,3 @@ export const getAuthToken = (username: string, res?: Response): string => {
 
   return token;
 };
-
-export const authHTTPBasicAuthenticationSchema = (authorization?: string) => {
-  if (authorization === undefined) {
-    return authAuthenticationSchema.safeParse({});
-  }
-
-  const credentials = httpBasicAuth(authorization);
-  if (credentials === null) {
-    return authAuthenticationSchema.safeParse({});
-  }
-
-  return authAuthenticationSchema.safeParse({username: credentials[0], password: credentials[1]});
-};
diff --git a/shared/schema/Auth.ts b/shared/schema/Auth.ts
index c3004789..19a9f9b0 100644
--- a/shared/schema/Auth.ts
+++ b/shared/schema/Auth.ts
@@ -4,7 +4,14 @@ import type {infer as zodInfer} from 'zod';
 import {AccessLevel} from './constants/Auth';
 import {clientConnectionSettingsSchema} from './ClientConnectionSettings';
 
-export type AuthMethod = 'default' | 'httpbasic' | 'none';
+export type AuthMethod = 'default' | 'header' | 'none';
+
+interface BasicAuthorizationHeader {
+  type: 'Basic';
+  username: string;
+}
+
+export type AuthorizationHeader = BasicAuthorizationHeader;
 
 export const credentialsSchema = object({
   username: string(),

@zawapete
Copy link
Contributor Author

zawapete commented Nov 1, 2020

I made a patch that hopefully makes the logics cleaner. Please apply if possible.

I applied the patch and as far as I can see it's changing the behaviour of my code.

one example for /api/auth/authenticate:

  if (config.authMethod === 'header') {
    const {username} = parseAuthorizationHeader(req.headers.authorization) || {};
    if (username == null) {
      res.json(403).send();
      return;
    }

    sendAuthenticationResponse(res, {username, level: AccessLevel.USER});
    return;
  }

now the user level is hardcoded to USER and the http basic credentials are not validate against the db (we still need to run Users.comparePassword()

I will keep your parseAuthorizationHeader(), let it return also the password and reverse my behaviour.

@jesec
Copy link
Owner

jesec commented Nov 1, 2020

I made a patch that hopefully makes the logics cleaner. Please apply if possible.

I applied the patch and as far as I can see it's changing the behaviour of my code.

one example for /api/auth/authenticate:

  if (config.authMethod === 'header') {
    const {username} = parseAuthorizationHeader(req.headers.authorization) || {};
    if (username == null) {
      res.json(403).send();
      return;
    }

    sendAuthenticationResponse(res, {username, level: AccessLevel.USER});
    return;
  }

now the user level is hardcoded to USER and the http basic credentials are not validate against the db (we still need to run Users.comparePassword()

I will keep your parseAuthorizationHeader(), let it return also the password and reverse my behaviour.

Password is not used and it is guaranteed to fail to validate password. I assume you want to verify that the user exists. You don't have to. It is handled by /verify and once frontend knows that current user is not registered, it won't call /authenticate endpoint. Plus, cookie validation won't succeed with a non-existing user anyways.

User level is another design problem that has to be addressed. I think no user should have the privilege to create users in this method as created users won't work if there is no corresponding one in the web server.

@zawapete
Copy link
Contributor Author

zawapete commented Nov 1, 2020

Password is not used and it is guaranteed to fail to validate password. I assume you want to verify that the user exists. You don't have to. It is handled by /verify and once frontend knows that current user is not registered, it won't call /authenticate endpoint. Plus, cookie validation won't succeed with a non-existing user anyways.

I would rather enforce that the full credentials between http basic and flood db are matching.
I see as it should keep the same behaviour as the default method but just reading credentials from authorization header

User level is another design problem that has to be addressed. I think no user should have the privilege to create users in this method as created users won't work if there is no corresponding one in the web server.

Since we cannot create in the flood backend a corresponding user in the web server this step will be always "asynchronous" from the creation of the user in flood. It's part of the user creation process in flood when header authentication is enabled. Also creating a user in flood it is not only about the credententials, but also for client settings.
It's up to the admin to add on the webserver the same credentials that he set for the user he created in flood.
Removing this option basically lead to a single user setup.

I will push another commit and let me know what do you think.

@zawapete
Copy link
Contributor Author

zawapete commented Nov 1, 2020

uhm, I'd still need to change /verify to use passport.authenticate('jwt') or http basic credentials (and then fetching the user and comparing password): I've lost autologin feature once I fill http credentials otherwise. that was the main thing that urged me to create the PR :)

@zawapete
Copy link
Contributor Author

zawapete commented Nov 1, 2020

I don't understand why test are failing when running the whole suite, and not the single one

@jesec
Copy link
Owner

jesec commented Nov 2, 2020

Password is not used and it is guaranteed to fail to validate password. I assume you want to verify that the user exists. You don't have to. It is handled by /verify and once frontend knows that current user is not registered, it won't call /authenticate endpoint. Plus, cookie validation won't succeed with a non-existing user anyways.

I would rather enforce that the full credentials between http basic and flood db are matching.
I see as it should keep the same behaviour as the default method but just reading credentials from authorization header

User level is another design problem that has to be addressed. I think no user should have the privilege to create users in this method as created users won't work if there is no corresponding one in the web server.

Since we cannot create in the flood backend a corresponding user in the web server this step will be always "asynchronous" from the creation of the user in flood. It's part of the user creation process in flood when header authentication is enabled. Also creating a user in flood it is not only about the credententials, but also for client settings.
It's up to the admin to add on the webserver the same credentials that he set for the user he created in flood.
Removing this option basically lead to a single user setup.

I will push another commit and let me know what do you think.

It simply doesn't make sense to validate password as the user is supposed to be already authenticated when the request reaches Flood. It also creates unnecessary data protection liability (one of the major benefits of this method is that Flood doesn't handle or store the password) and will certainly cause issues when the password of user changes in the web server. For the user creation, per the design, if the user is not configured, the registration screen will show and let the user fill out the connection settings. There is no point to allow other users to create users which won't work anyways if the user is not authenticated by the web server.

@zawapete
Copy link
Contributor Author

zawapete commented Nov 2, 2020

It simply doesn't make sense to validate password as the user is supposed to be already authenticated when the request reaches Flood.

It's authenticated in the webserver, not in flood. It's not supposed to be authenticated in flood. Flood should ensure its authentication process on the data sent on the header, and not completely delegate to them the authentication process.
If the user was already authenticated we could have kept the jwt token generated by header information and bypass the cookie, but we didn't. The same case here.

There is no point to allow other users to create users which won't work anyways if the user is not authenticated by the web server.

It's the whole point to allow other users to create users. They will work as soon as the user will be created also in the webserver. If the admin cannot create users then it's a single user setup. I personally have multiple users and I will use the header authentication so I know exactly what are the customer needs.

@jesec
Copy link
Owner

jesec commented Nov 2, 2020

It simply doesn't make sense to validate password as the user is supposed to be already authenticated when the request reaches Flood.

It's authenticated in the webserver, not in flood. It's not supposed to be authenticated in flood. Flood should ensure its authentication process on the data sent on the header, and not completely delegate to them the authentication process.
If the user was already authenticated we could have kept the jwt token generated by header information and bypass the cookie, but we didn't. The same case here.

The web server enforces the authentication process. Flood does not. Flood only checks the header and gets the username from it. We don't bypass the cookie because Basic header is sent by the browser with any request by any site (incl. malicious ones). It is done to prevent session-hijacking and it is not the same case.

Why should Flood validate the password if Flood doesn't store it? The validation process will fail.

There is no point to allow other users to create users which won't work anyways if the user is not authenticated by the web server.

It's the whole point to allow other users to create users. They will work as soon as the user will be created also in the webserver. If the admin cannot create users then it's a single user setup. I personally have multiple users and I will use the header authentication so I know exactly what are the customer needs.

I advise that you either stipulate that only the initial user is administrator and allows it to programmatically create users via API. Or define a special username that be treated as administrator. (_config is a good choice)

Other users shall not be treated as administrators. You don't want all users to have the authority to create or delete other users.

@zawapete zawapete closed this Nov 8, 2020
@zawapete zawapete reopened this Nov 8, 2020
@zawapete
Copy link
Contributor Author

zawapete commented Nov 8, 2020

The web server enforces the authentication process. Flood does not. Flood only checks the header and gets the username from it. We don't bypass the cookie because Basic header is sent by the browser with any request by any site (incl. malicious ones). It is done to prevent session-hijacking and it is not the same case.

I made you already an example where the webserver authentication could not match the flood credentials.
Since I wrote the PR according to my needs I clearly understand all the use cases and how the flow should be implemented. The original httpbasic method auth (now header) it is only supposed to change in the way to provide credentials to flood: by basic authentication header instead as payload in the body. That's it. The user management is still up to flood, because that's what I need (and I would say it makes sense in a general case).

I advise that you either stipulate that only the initial user is administrator and allows it to programmatically create users via API. Or define a special username that be treated as administrator. (_config is a good choice)
Other users shall not be treated as administrators. You don't want all users to have the authority to create or delete other users.

So, I think it is now time for you to tell me if you are open to accept contributions according to real users' needs or if you want to drive the contributions according to your agend regardless what're the needs of the users.
In the second case I'm sorry but I'm not interested to contribute for something that I won't be the first one to use or, even worse, will decrease my user experience.

All comments and request for changes about code style, technical solution etc I'm happy to abide.

@jesec
Copy link
Owner

jesec commented Nov 8, 2020

It might not match the credentials but it is not because of the password differences. But because the potential re-authentication may change the logged in user and thus the username in the header may not match the username in the cookie.

Flood simply have no need to store the password if it doesn’t authenticate. It should merely assign the token of authenticated user based on its username. The web server does the authentication not the Flood.

I would conclude that there is absolutely no benefit if such authentication method requires storing of the password and duplicate authentication.

@zawapete
Copy link
Contributor Author

Flood simply have no need to store the password if it doesn’t authenticate. It should merely assign the token of authenticated user based on its username. The web server does the authentication not the Flood.

The original httpbasic method auth (now header) it is only supposed to change in the way to provide credentials to flood: by basic authentication header instead as payload in the body.

that's it. the web server does not do any authentication: that's the business requirements given by the customers' need.
the authorization header is used to provide flood the credentials instead of the login form.

I would conclude that there is absolutely no benefit if such authentication method requires storing of the password and duplicate authentication.

the benefit are avoiding all workarounds and hacks that your implementation requires like the following:

I advise that you either stipulate that only the initial user is administrator and allows it to programmatically create users via API. Or define a special username that be treated as administrator. (_config is a good choice)
Other users shall not be treated as administrators. You don't want all users to have the authority to create or delete other users.

I will repeat again to be clear:

  1. the web server was never intended to perform authentication
  2. the authorization header in the http request is used to provide credentials to flood instead of the login form
  3. users management will remain concern of flood regardless of the method used to provide credentials

so, the questions:

  1. do you accept that there are customers's need that might not fit your specific usage pattern?
  2. given the business requirements are valid, how to fulfill the three criteria above?

@jesec
Copy link
Owner

jesec commented Mar 17, 2021

that's it. the web server does not do any authentication: that's the business requirements given by the customers' need.
the authorization header is used to provide flood the credentials instead of the login form.

Then who will perform the authentication? Flood? Why not simply use Flood's login/register directly then?

I will repeat again to be clear:

  1. the web server was never intended to perform authentication
  2. the authorization header in the http request is used to provide credentials to flood instead of the login form

HTTP basic authentication workflow is more than just a header. If you want Flood to authenticate with HTTP basic auth, you have to implement the necessary functions.

  1. users management will remain concern of flood regardless of the method used to provide credentials

Like websites that has "Google" / "Facebook" logins, if there is a new user, Flood might display a "register" page (with username/password fields greyed out) that allows user to set connection settings (similar to "setting up your profile" steps).

Alternatively, an administrator may purely use Flood's API and a built-in management user to add/delete/modify users in Flood's database while users are authenticated by a web server. In this case, Flood matches the profile via username in the header and does not try to match the password (as the user is already authenticated when the request reaches Flood).

@zawapete
Copy link
Contributor Author

Then who will perform the authentication? Flood? Why not simply use Flood's login/register directly then?

because of seamless user experience: flood is behind a proxy that requires http based authentication in order to serve the content. I don't want the users to be prompted twice for the same credentials. while the users management should be kept fully featured in flood regardless of how the credentials were provided

HTTP basic authentication workflow is more than just a header. If you want Flood to authenticate with HTTP basic auth, you have to implement the necessary functions.

as I said before, and I guess it's where there is the misunderstanding about the feature, is that the the whole http basic authentication workflow should not be involved in the flood authentication process: it's just a matter of forwarding the credentials from the http basic authentication workflow to flood and let flood use those credentials for its own process.

the only concern I can see about this model is that other "pre-emptive" authentication methods (let's call them so: I intend authentication method that are required to access the flood content, without setting the authentication state in flood itself) may not provide a set of credentials that once forwarded to flood will allow a login based authentication there.
this has to be understood and be found a way how to overcome.

Like websites that has "Google" / "Facebook" logins, if there is a new user, Flood might display a "register" page (with username/password fields greyed out) that allows user to set connection settings (similar to "setting up your profile" steps).

That's exaclty what I've implemented: https://github.com/jesec/flood/pull/70/files#diff-331447558aa67509abffb409e0b90f2cb6428c66ea602f71084033a4dc67caa1R161-R164

It's true only when there's no user registered yet, since this is exactly the way flood works.
Only the first user is created by the registration page, all the others are managed by admin in the users management settings.

Alternatively, an administrator may purely use Flood's API and a built-in management user to add/delete/modify users in Flood's database while users are authenticated by a web server.

this is still a poor UX experience, while limited to admin users.

as the user is already authenticated when the request reaches Flood

I repeat it again :)
The http request in order to access flood content is authenticated, otherwise you'll get a 401. being authenticated and receiving a 200 is about the authentication status of the http request/response from the proxy/web server. I don't see as this authentication status should be involved in the authentication status of flood.
at the same time we have credentials data accessibile from the http authentication workflow that can be forwarded to flood in order to process its own authentication status.

@jesec
Copy link
Owner

jesec commented Mar 18, 2021

Then who will perform the authentication? Flood? Why not simply use Flood's login/register directly then?

because of seamless user experience: flood is behind a proxy that requires http based authentication in order to serve the content. I don't want the users to be prompted twice for the same credentials. while the users management should be kept fully featured in flood regardless of how the credentials were provided

HTTP basic authentication workflow is more than just a header. If you want Flood to authenticate with HTTP basic auth, you have to implement the necessary functions.

as I said before, and I guess it's where there is the misunderstanding about the feature, is that the the whole http basic authentication workflow should not be involved in the flood authentication process: it's just a matter of forwarding the credentials from the http basic authentication workflow to flood and let flood use those credentials for its own process.

If the user is already authenticated, there is no reason to authenticate them again. Simply extract the username and match the correct profile/services. There is absolutely no reason to introduce unnecessary data liabilities and potential inconsistencies (suppose the password is changed on one side only) by storing and matching the password.

@zawapete
Copy link
Contributor Author

If the user is already authenticated, there is no reason to authenticate them again

the user is authenticated to the proxy, not to flood.
let's change the perspective: while the autentication to the proxy should imply an authentication to flood in your opinion?

@jesec
Copy link
Owner

jesec commented Mar 18, 2021

If the user is already authenticated, there is no reason to authenticate them again

the user is authenticated to the proxy, not to flood.
let's change the perspective: while the autentication to the proxy should imply an authentication to flood in your opinion?

Yes. The proxy already authenticated the incoming request. By the time the request reaches Flood, the user is an authenticated user. There is no reason to authenticate again.

@zawapete
Copy link
Contributor Author

The proxy already authenticated the incoming request.

the request is authenticated in the proxy domain.

my question is (I mispelled while for why):
why the proxy authentication should imply flood authentication?
in my opinion it shouldn't, why should it for you?

@jesec
Copy link
Owner

jesec commented Mar 18, 2021

The proxy already authenticated the incoming request.

the request is authenticated in the proxy domain.

my question is (I mispelled while for why):
why the proxy authentication should imply flood authentication?
in my opinion it shouldn't, why should it for you?

Because it supposes to. You want to avoid duplicate authentication, that's your goal, no?

OK. Suppose we keep the password authentication there, what will happen when there are different passwords in Flood's database and your proxy's database?

@zawapete
Copy link
Contributor Author

Because it supposes to. You want to avoid duplicate authentication, that's your goal, no?

my goal is not to avoid duplicate authentication: my goal is avoiding poor UX experience that requires providing two times the credentials. so, it doesn't suppose to, giving my goal.

at the contrary, I strongly believe that the two services involved have far different domain and the authentication process of both should be separated and processed accordingly, only sharing the bit of information the can be applied to both (in this case: username and password)

but I'm open to try to understand why it should suppose to from your point of view.

OK. Suppose we keep the password authentication there, what will happen when there are different passwords in Flood's database and your proxy's database?

flood won't authenticate. exactly because proxy and flood are two different domains. it's up to the maintainer of the system to keep the credentials in sync. I don't see the feature as delegating authentication/credentials storage to an external source. in the future I could remove the proxy, or I could switch to other mechanism in order to access flood resources and I should still be able to authenticate through flood because it owns his own users management/authentication process.

@jesec
Copy link
Owner

jesec commented Mar 18, 2021

flood won't authenticate. exactly because proxy and flood are two different domains. it's up to the maintainer of the system to keep the credentials in sync. I don't see the feature as delegating authentication/credentials storage to an external source. in the future I could remove the proxy, or I could switch to other mechanism in order to access flood resources and I should still be able to authenticate through flood because it owns his own users management/authentication process.

Well. There is virtually zero benefit to have such mechanism. Current implementation also greatly increases complexity around the auth codes and confuses users.

If you only want something like login with HTTP header, I would advise you to drastically scale down this change.

@zawapete
Copy link
Contributor Author

If you only want something like login with HTTP header, I would advise you to drastically scale down this change.

I remember that my first implementation was to prefill login and registration form (and autosubmit the first)
This is the comment you left: #64 (comment), asking me to implement in the backend.

If you have any other way to scale down this change different from the original frontend implementation please share :)

@zawapete
Copy link
Contributor Author

@jesec any feedback on this?

@jesec
Copy link
Owner

jesec commented Apr 22, 2021

I am not convinced that this PR is useful. I also have concerns about the increased complexity.

@zawapete
Copy link
Contributor Author

I am not convinced that this PR is useful. I also have concerns about the increased complexity.

we are talking about a completely different implementation, reducing the complexity, as you said in your last comment.

my question was if the original implementation in frontend (that you asked to change for an implementation in backend) would be low complexity enough

as for the usefulness of the feature: it answers to a customer need.

@jesec
Copy link
Owner

jesec commented Apr 23, 2021

I am not convinced that this PR is useful. I also have concerns about the increased complexity.

we are talking about a completely different implementation, reducing the complexity, as you said in your last comment.

my question was if the original implementation in frontend (that you asked to change for an implementation in backend) would be low complexity enough

as for the usefulness of the feature: it answers to a customer need.

You can update the PR to show such implementation.

Still, I think it is a bad idea to mix two distinct authentication system. Ideally, only one component should do the authentication.

Hyeronimous Zawapete added 2 commits April 23, 2021 20:27
@zawapete
Copy link
Contributor Author

You can update the PR to show such implementation.

I tried to recover the squashed commit that I discarded back then. Pushed now. Probably not working, but just to give an idea.

Still, I think it is a bad idea to mix two distinct authentication system. Ideally, only one component should do the authentication.

that's exactly my point and what I did initially: just forwarding/prefilling the credentials from http basic auth to the backend and keep the authentication only in flood. it was you that asked for a different solution

@zawapete
Copy link
Contributor Author

@jesec any feedback?

@jesec
Copy link
Owner

jesec commented May 10, 2021

It is still very complicated, and I don't think the potential benefits worth it.

@zawapete
Copy link
Contributor Author

I don't think the potential benefits worth it

then instead of letting me rewrite the PR four times you could have honestly answered, when I asked, that you are not open to accept contributions according to real users' needs and that you want to drive the contributions according to your agenda regardless what're the needs of the users

I'm honestly worried that you took over rtorrent development as well

have fun with your toys

@zawapete zawapete closed this May 13, 2021
@zawapete zawapete deleted the HTTP_BASIC_AUTH_HANDLER branch May 13, 2021 18:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants