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.
This commit is contained in:
Ryan Palmer
2024-09-16 08:32:33 +10:00
parent 9d0331d04f
commit 6ad469a115
9 changed files with 138 additions and 25 deletions

View File

@ -7,14 +7,17 @@ APP_SECRET=REPLACE_WITH_LONG_SECRET
JWT_TOKEN_EXPIRES_IN=30d JWT_TOKEN_EXPIRES_IN=30d
# Use NTLM for user authentication # Use NTLM for user authentication, exposes to browser
NTLM_AUTH=false VITE_NTLM_AUTH=false
# LDAP settings for NTLM authentication # LDAP settings for NTLM authentication
LDAP_BASEDN= LDAP_BASEDN=
LDAP_DOMAINSUFFIX= LDAP_DOMAINSUFFIX=
LDAP_USERNAME= LDAP_USERNAME=
LDAP_PASSWORD= LDAP_PASSWORD=
# User object attributes for docmost
LDAP_NAMEATTRIBUTE=
LDAP_MAILATTRIBUTE=
DATABASE_URL="postgresql://postgres:password@localhost:5432/docmost?schema=public" DATABASE_URL="postgresql://postgres:password@localhost:5432/docmost?schema=public"
REDIS_URL=redis://127.0.0.1:6379 REDIS_URL=redis://127.0.0.1:6379

View File

@ -1,5 +1,5 @@
import { useState } from "react"; 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 { useNavigate } from "react-router-dom";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom"; 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) => { const handleInvitationSignUp = async (data: IAcceptInvite) => {
setIsLoading(true); setIsLoading(true);
@ -107,6 +126,7 @@ export default function useAuth() {
return { return {
signIn: handleSignIn, signIn: handleSignIn,
ntlmSignIn: handleNtlmSignIn,
invitationSignup: handleInvitationSignUp, invitationSignup: handleInvitationSignUp,
setupWorkspace: handleSetupWorkspace, setupWorkspace: handleSetupWorkspace,
isAuthenticated: handleIsAuthenticated, isAuthenticated: handleIsAuthenticated,

View File

@ -6,12 +6,19 @@ import {
ISetupWorkspace, ISetupWorkspace,
ITokenResponse, ITokenResponse,
} from "@/features/auth/types/auth.types"; } from "@/features/auth/types/auth.types";
import axios from "axios";
export async function login(data: ILogin): Promise<ITokenResponse> { export async function login(data: ILogin): Promise<ITokenResponse> {
const req = await api.post<ITokenResponse>("/auth/login", data); const req = await api.post<ITokenResponse>("/auth/login", data);
return req.data; return req.data;
} }
export async function ntlmLogin(): Promise<ITokenResponse> {
// Use separate axios instance to avoid passing app auth headers to allow for NTLM authentication challenge
const req = await axios.post<ITokenResponse>("/api/auth/ntlm");
return req.data;
}
/* /*
export async function register(data: IRegister): Promise<ITokenResponse> { export async function register(data: IRegister): Promise<ITokenResponse> {
const req = await api.post<ITokenResponse>("/auth/register", data); const req = await api.post<ITokenResponse>("/auth/register", data);

View File

@ -1,13 +1,28 @@
import { LoginForm } from "@/features/auth/components/login-form"; 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"; import { Helmet } from "react-helmet-async";
const ntlmAuth = import.meta.env.VITE_NTLM_AUTH;
export default function LoginPage() { export default function LoginPage() {
const { ntlmSignIn } = useAuth();
useEffect(() => {
if (ntlmAuth)
ntlmSignIn();
}, [])
return ( return (
<> <>
<Helmet> <Helmet>
<title>Login</title> <title>Login</title>
</Helmet> </Helmet>
<LoginForm /> {!ntlmAuth && <LoginForm />}
</> </>
); );
} }

View File

@ -10,5 +10,6 @@ import { TokenModule } from './token.module';
imports: [TokenModule, WorkspaceModule], imports: [TokenModule, WorkspaceModule],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, SignupService, JwtStrategy], providers: [AuthService, SignupService, JwtStrategy],
exports: [AuthService]
}) })
export class AuthModule {} export class AuthModule {}

View File

@ -126,6 +126,21 @@ export class EnvironmentService {
return this.configService.get<string>('LDAP_PASSWORD') return this.configService.get<string>('LDAP_PASSWORD')
} }
getLdapNameAttribute(): string {
return this.configService.get<string>('LDAP_NAMEATTRIBUTE')
}
getLdapMailAttribute(): string {
return this.configService.get<string>('LDAP_MAILATTRIBUTE')
}
usingNtlmAuth(): boolean {
const ntlmAuth = this.configService
.get<string>('VITE_NTLM_AUTH', 'false')
.toLowerCase();
return ntlmAuth === 'true';
}
isCloud(): boolean { isCloud(): boolean {
const cloudConfig = this.configService const cloudConfig = this.configService
.get<string>('CLOUD', 'false') .get<string>('CLOUD', 'false')

View File

@ -5,6 +5,7 @@ import {
Res, Res,
HttpException, HttpException,
HttpStatus, HttpStatus,
Post,
} from '@nestjs/common'; } from '@nestjs/common';
import { FastifyRequest, FastifyReply } from 'fastify'; import { FastifyRequest, FastifyReply } from 'fastify';
@ -21,19 +22,18 @@ import { NTLMService } from './ntlm.service';
@Controller() @Controller()
export class NTLMController { export class NTLMController {
constructor(private readonly ntlmService: NTLMService) {} constructor(
private readonly ntlmService: NTLMService,
private readonly environmentService: EnvironmentService,
) {}
@Get('auth/ntlm') @Post('auth/ntlm')
async ntlmAuth( async ntlmAuth(@Req() req, @Res() res: FastifyReply) {
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
): Promise<void> {
const authHeader = req.headers['authorization']; const authHeader = req.headers['authorization'];
if (!authHeader) { if (!authHeader) {
// Step 1: Challenge the client for NTLM authentication // Step 1: Challenge the client for NTLM authentication
res.status(401).header('WWW-Authenticate', 'NTLM').send(); return res.status(401).header('WWW-Authenticate', 'NTLM').send();
return;
} }
if (authHeader.startsWith('NTLM ')) { if (authHeader.startsWith('NTLM ')) {
@ -45,11 +45,10 @@ export class NTLMController {
const serverChallenge = new NTLMChallengeMessage(clientNegotiation); const serverChallenge = new NTLMChallengeMessage(clientNegotiation);
const base64Challenge = serverChallenge.toBuffer().toString('base64'); const base64Challenge = serverChallenge.toBuffer().toString('base64');
res return res
.status(401) .status(401)
.header('WWW-Authenticate', `NTLM ${base64Challenge}`) .header('WWW-Authenticate', `NTLM ${base64Challenge}`)
.send(); .send();
return;
} else if (clientNegotiation.messageType === MessageType.AUTHENTICATE) { } else if (clientNegotiation.messageType === MessageType.AUTHENTICATE) {
// Step 4: Handle NTLM Authenticate message // Step 4: Handle NTLM Authenticate message
const clientAuthentication = new NTLMAuthenticateMessage(authHeader); const clientAuthentication = new NTLMAuthenticateMessage(authHeader);
@ -68,13 +67,17 @@ export class NTLMController {
filter: `(userPrincipalName=${clientAuthentication.userName}@${clientAuthentication.domainName}*)`, filter: `(userPrincipalName=${clientAuthentication.userName}@${clientAuthentication.domainName}*)`,
}); });
// Assuming authentication is successful if (results.length == 1) {
res.status(200).send(results); const ntlmSignInResult = await this.ntlmService.login(
return; 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 { } else {
console.warn('Invalid NTLM Message received.'); console.warn('Invalid NTLM Message received.');
res.status(400).send('Invalid NTLM Message'); return res.status(400).send('Invalid NTLM Message');
return;
} }
} }

View File

@ -1,9 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { NTLMController } from './ntlm.controller'; import { NTLMController } from './ntlm.controller';
import { NTLMService } from './ntlm.service'; 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({ @Module({
controllers: [NTLMController], imports: [TokenModule, WorkspaceModule, AuthModule],
providers: [NTLMService] controllers: [NTLMController],
}) providers: [NTLMService],
export class NTLMModule {} })
export class NTLMModule {}

View File

@ -1,10 +1,19 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { EnvironmentService } from '../environment/environment.service'; import { EnvironmentService } from '../environment/environment.service';
import * as ldap from 'ldapjs'; 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() @Injectable()
export class NTLMService { 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) => createClient = (domain: string) =>
ldap.createClient({ 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;
}
} }