From 6ad469a11564de4235a2f5b417baa9f6ea285097 Mon Sep 17 00:00:00 2001 From: Ryan Palmer Date: Mon, 16 Sep 2024 08:32:33 +1000 Subject: [PATCH] Minimum viable NTLM auth implementation Added env variable "VITE_NTLM_AUTH", if true, login page will attempt NTLM auth challenge instead of showing login page. If challenge is successful and an authenticate message is received, it will check for the existence of the user using the provided mail attribute, and create an account with a random, complex password, and then authenticate as the user. --- .env.example | 7 ++- .../src/features/auth/hooks/use-auth.ts | 22 ++++++++- .../features/auth/services/auth-service.ts | 7 +++ apps/client/src/pages/auth/login.tsx | 17 ++++++- apps/server/src/core/auth/auth.module.ts | 1 + .../environment/environment.service.ts | 15 ++++++ .../src/integrations/ntlm/ntlm.controller.ts | 33 +++++++------ .../src/integrations/ntlm/ntlm.module.ts | 12 +++-- .../src/integrations/ntlm/ntlm.service.ts | 49 ++++++++++++++++++- 9 files changed, 138 insertions(+), 25 deletions(-) diff --git a/.env.example b/.env.example index 8a7ab6e..e851513 100644 --- a/.env.example +++ b/.env.example @@ -7,14 +7,17 @@ APP_SECRET=REPLACE_WITH_LONG_SECRET JWT_TOKEN_EXPIRES_IN=30d -# Use NTLM for user authentication -NTLM_AUTH=false +# Use NTLM for user authentication, exposes to browser +VITE_NTLM_AUTH=false # LDAP settings for NTLM authentication LDAP_BASEDN= LDAP_DOMAINSUFFIX= LDAP_USERNAME= LDAP_PASSWORD= +# User object attributes for docmost +LDAP_NAMEATTRIBUTE= +LDAP_MAILATTRIBUTE= DATABASE_URL="postgresql://postgres:password@localhost:5432/docmost?schema=public" REDIS_URL=redis://127.0.0.1:6379 diff --git a/apps/client/src/features/auth/hooks/use-auth.ts b/apps/client/src/features/auth/hooks/use-auth.ts index ede22a0..56b89ea 100644 --- a/apps/client/src/features/auth/hooks/use-auth.ts +++ b/apps/client/src/features/auth/hooks/use-auth.ts @@ -1,5 +1,5 @@ import { useState } from "react"; -import { login, setupWorkspace } from "@/features/auth/services/auth-service"; +import { login, ntlmLogin, setupWorkspace } from "@/features/auth/services/auth-service"; import { useNavigate } from "react-router-dom"; import { useAtom } from "jotai"; import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom"; @@ -38,6 +38,25 @@ export default function useAuth() { } }; + const handleNtlmSignIn = async () => { + setIsLoading(true); + + try { + const res = await ntlmLogin(); + setIsLoading(false); + setAuthToken(res.tokens); + + navigate(APP_ROUTE.HOME); + } catch (err) { + console.log(err); + setIsLoading(false); + notifications.show({ + message: err.response?.data.message, + color: "red", + }); + } + }; + const handleInvitationSignUp = async (data: IAcceptInvite) => { setIsLoading(true); @@ -107,6 +126,7 @@ export default function useAuth() { return { signIn: handleSignIn, + ntlmSignIn: handleNtlmSignIn, invitationSignup: handleInvitationSignUp, setupWorkspace: handleSetupWorkspace, isAuthenticated: handleIsAuthenticated, diff --git a/apps/client/src/features/auth/services/auth-service.ts b/apps/client/src/features/auth/services/auth-service.ts index b34b47f..6a56679 100644 --- a/apps/client/src/features/auth/services/auth-service.ts +++ b/apps/client/src/features/auth/services/auth-service.ts @@ -6,12 +6,19 @@ import { ISetupWorkspace, ITokenResponse, } from "@/features/auth/types/auth.types"; +import axios from "axios"; export async function login(data: ILogin): Promise { const req = await api.post("/auth/login", data); return req.data; } +export async function ntlmLogin(): Promise { + // Use separate axios instance to avoid passing app auth headers to allow for NTLM authentication challenge + const req = await axios.post("/api/auth/ntlm"); + return req.data; +} + /* export async function register(data: IRegister): Promise { const req = await api.post("/auth/register", data); diff --git a/apps/client/src/pages/auth/login.tsx b/apps/client/src/pages/auth/login.tsx index c3f47be..e08941a 100644 --- a/apps/client/src/pages/auth/login.tsx +++ b/apps/client/src/pages/auth/login.tsx @@ -1,13 +1,28 @@ import { LoginForm } from "@/features/auth/components/login-form"; +import useAuth from "@/features/auth/hooks/use-auth"; +import { useEffect } from "react"; import { Helmet } from "react-helmet-async"; + +const ntlmAuth = import.meta.env.VITE_NTLM_AUTH; + export default function LoginPage() { + + const { ntlmSignIn } = useAuth(); + + useEffect(() => { + + if (ntlmAuth) + ntlmSignIn(); + + }, []) + return ( <> Login - + {!ntlmAuth && } ); } diff --git a/apps/server/src/core/auth/auth.module.ts b/apps/server/src/core/auth/auth.module.ts index eeb68f5..44f66e4 100644 --- a/apps/server/src/core/auth/auth.module.ts +++ b/apps/server/src/core/auth/auth.module.ts @@ -10,5 +10,6 @@ import { TokenModule } from './token.module'; imports: [TokenModule, WorkspaceModule], controllers: [AuthController], providers: [AuthService, SignupService, JwtStrategy], + exports: [AuthService] }) export class AuthModule {} diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts index 6c535d1..57c1e90 100644 --- a/apps/server/src/integrations/environment/environment.service.ts +++ b/apps/server/src/integrations/environment/environment.service.ts @@ -126,6 +126,21 @@ export class EnvironmentService { return this.configService.get('LDAP_PASSWORD') } + getLdapNameAttribute(): string { + return this.configService.get('LDAP_NAMEATTRIBUTE') + } + + getLdapMailAttribute(): string { + return this.configService.get('LDAP_MAILATTRIBUTE') + } + + usingNtlmAuth(): boolean { + const ntlmAuth = this.configService + .get('VITE_NTLM_AUTH', 'false') + .toLowerCase(); + return ntlmAuth === 'true'; + } + isCloud(): boolean { const cloudConfig = this.configService .get('CLOUD', 'false') diff --git a/apps/server/src/integrations/ntlm/ntlm.controller.ts b/apps/server/src/integrations/ntlm/ntlm.controller.ts index 9fe0a66..e36f507 100644 --- a/apps/server/src/integrations/ntlm/ntlm.controller.ts +++ b/apps/server/src/integrations/ntlm/ntlm.controller.ts @@ -5,6 +5,7 @@ import { Res, HttpException, HttpStatus, + Post, } from '@nestjs/common'; import { FastifyRequest, FastifyReply } from 'fastify'; @@ -21,19 +22,18 @@ import { NTLMService } from './ntlm.service'; @Controller() export class NTLMController { - constructor(private readonly ntlmService: NTLMService) {} + constructor( + private readonly ntlmService: NTLMService, + private readonly environmentService: EnvironmentService, + ) {} - @Get('auth/ntlm') - async ntlmAuth( - @Req() req: FastifyRequest, - @Res() res: FastifyReply, - ): Promise { + @Post('auth/ntlm') + async ntlmAuth(@Req() req, @Res() res: FastifyReply) { const authHeader = req.headers['authorization']; if (!authHeader) { // Step 1: Challenge the client for NTLM authentication - res.status(401).header('WWW-Authenticate', 'NTLM').send(); - return; + return res.status(401).header('WWW-Authenticate', 'NTLM').send(); } if (authHeader.startsWith('NTLM ')) { @@ -45,11 +45,10 @@ export class NTLMController { const serverChallenge = new NTLMChallengeMessage(clientNegotiation); const base64Challenge = serverChallenge.toBuffer().toString('base64'); - res + return res .status(401) .header('WWW-Authenticate', `NTLM ${base64Challenge}`) .send(); - return; } else if (clientNegotiation.messageType === MessageType.AUTHENTICATE) { // Step 4: Handle NTLM Authenticate message const clientAuthentication = new NTLMAuthenticateMessage(authHeader); @@ -68,13 +67,17 @@ export class NTLMController { filter: `(userPrincipalName=${clientAuthentication.userName}@${clientAuthentication.domainName}*)`, }); - // Assuming authentication is successful - res.status(200).send(results); - return; + if (results.length == 1) { + const ntlmSignInResult = await this.ntlmService.login( + results.at(0)[this.environmentService.getLdapNameAttribute()], + results.at(0)[this.environmentService.getLdapMailAttribute()], + req.raw.workspaceId, + ); + return res.status(200).send(ntlmSignInResult); + } else return res.status(403).send(); } else { console.warn('Invalid NTLM Message received.'); - res.status(400).send('Invalid NTLM Message'); - return; + return res.status(400).send('Invalid NTLM Message'); } } diff --git a/apps/server/src/integrations/ntlm/ntlm.module.ts b/apps/server/src/integrations/ntlm/ntlm.module.ts index 169b576..2b4c761 100644 --- a/apps/server/src/integrations/ntlm/ntlm.module.ts +++ b/apps/server/src/integrations/ntlm/ntlm.module.ts @@ -1,9 +1,13 @@ import { Module } from '@nestjs/common'; import { NTLMController } from './ntlm.controller'; import { NTLMService } from './ntlm.service'; +import { TokenModule } from 'src/core/auth/token.module'; +import { WorkspaceModule } from 'src/core/workspace/workspace.module'; +import { AuthModule } from 'src/core/auth/auth.module'; @Module({ - controllers: [NTLMController], - providers: [NTLMService] - }) - export class NTLMModule {} + imports: [TokenModule, WorkspaceModule, AuthModule], + controllers: [NTLMController], + providers: [NTLMService], +}) +export class NTLMModule {} diff --git a/apps/server/src/integrations/ntlm/ntlm.service.ts b/apps/server/src/integrations/ntlm/ntlm.service.ts index 8175d25..e53d612 100644 --- a/apps/server/src/integrations/ntlm/ntlm.service.ts +++ b/apps/server/src/integrations/ntlm/ntlm.service.ts @@ -1,10 +1,19 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; import { EnvironmentService } from '../environment/environment.service'; import * as ldap from 'ldapjs'; +import { UserRepo } from '@docmost/db/repos/user/user.repo'; +import { TokensDto } from 'src/core/auth/dto/tokens.dto'; +import { TokenService } from 'src/core/auth/services/token.service'; +import { AuthService } from 'src/core/auth/services/auth.service'; @Injectable() export class NTLMService { - constructor(private readonly environmentService: EnvironmentService) {} + constructor( + private readonly environmentService: EnvironmentService, + private authService: AuthService, + private tokenService: TokenService, + private userRepo: UserRepo, + ) {} createClient = (domain: string) => ldap.createClient({ @@ -62,4 +71,40 @@ export class NTLMService { }); }); }; + + async login(name: string, email: string, workspaceId: string) { + const user = await this.userRepo.findByEmail(email, workspaceId, false); + + if (!user) { + const tokensR = await this.authService.register( + { + name, + email, + password: this.generateRandomPassword(12), + }, + workspaceId, + ); + + return tokensR; + } + + user.lastLoginAt = new Date(); + await this.userRepo.updateLastLogin(user.id, workspaceId); + + const tokens: TokensDto = await this.tokenService.generateTokens(user); + return { tokens }; + } + + generateRandomPassword(length: number): string { + const characters = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+[]{}|;:,.<>?'; + let password = ''; + + for (let i = 0; i < length; i++) { + const randomIndex = Math.floor(Math.random() * characters.length); + password += characters[randomIndex]; + } + + return password; + } }