mirror of
https://github.com/Shadowfita/docmost.git
synced 2025-11-09 20:12:00 +10:00
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:
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -6,12 +6,19 @@ import {
|
||||
ISetupWorkspace,
|
||||
ITokenResponse,
|
||||
} from "@/features/auth/types/auth.types";
|
||||
import axios from "axios";
|
||||
|
||||
export async function login(data: ILogin): Promise<ITokenResponse> {
|
||||
const req = await api.post<ITokenResponse>("/auth/login", 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> {
|
||||
const req = await api.post<ITokenResponse>("/auth/register", data);
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Login</title>
|
||||
</Helmet>
|
||||
<LoginForm />
|
||||
{!ntlmAuth && <LoginForm />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,5 +10,6 @@ import { TokenModule } from './token.module';
|
||||
imports: [TokenModule, WorkspaceModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, SignupService, JwtStrategy],
|
||||
exports: [AuthService]
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@ -126,6 +126,21 @@ export class EnvironmentService {
|
||||
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 {
|
||||
const cloudConfig = this.configService
|
||||
.get<string>('CLOUD', 'false')
|
||||
|
||||
@ -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<void> {
|
||||
@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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user