diff --git a/src/controller/request/user-request.ts b/src/controller/request/user-request.ts index 2070ebbf..0f95c23e 100644 --- a/src/controller/request/user-request.ts +++ b/src/controller/request/user-request.ts @@ -60,11 +60,13 @@ export interface CreateUserRequest extends BaseUserRequest { * @property {boolean} deleted * @property {boolean} active * @property {boolean} extensiveDataProcessing + * @property {string} type */ export interface UpdateUserRequest extends Partial { active?: boolean; deleted?: boolean; extensiveDataProcessing?: boolean + type?: UserType } diff --git a/src/controller/user-controller.ts b/src/controller/user-controller.ts index b62647c8..b5d91751 100644 --- a/src/controller/user-controller.ts +++ b/src/controller/user-controller.ts @@ -335,6 +335,22 @@ export default class UserController extends BaseController { body: { modelName: 'WaiveFinesRequest', allowBlankTarget: true }, }, }, + '/:id(\\d+)/tolocaluser':{ + POST: { + policy: async (req) => this.roleManager.can( + req.token.roles, 'update', UserController.getRelation(req), 'Authenticator', ['pin'], + ), + handler: this.changeToLocalUser.bind(this), + }, + }, + '/:id(\\d+)/toLocalUser':{ + POST: { + policy: async (req) => this.roleManager.can( + req.token.roles, 'update', UserController.getRelation(req), 'Authenticator', ['*'], + ), + handler: this.changeToLocalUser.bind(this), + }, + }, }; } @@ -1650,4 +1666,36 @@ export default class UserController extends BaseController { this.logger.error(e); } } + + /** + * POST /users/{id}/tolocalUser + * @summary Change user to a local user + * @tags users - Operations of user controller + * @param {integer} id.path.required - The id of the user + * @operationId toLocalUser + * @security JWT + * @return {UserResponse} 200 - Return changed user + * @return {string} 404 - User not found error. + */ + public async changeToLocalUser(req: RequestWithToken, res:Response): Promise { + const { id: rawId } = req.params; + this.logger.trace('Change user to local user', rawId, 'by', req.token.user); + + try { + const id = parseInt(rawId, 10); + + const user = await User.findOne({ where: { id:id } }); + if (user == null) { + res.status(404).json('Unknown user ID.'); + return; + } + + const newUserResponse = await UserService.changeToLocalUsers(id); + res.status(200).json(newUserResponse); + } catch (e) { + res.status(500).send(); + this.logger.error(e); + } + + } } diff --git a/src/mailer/messages/user-to-local-user.ts b/src/mailer/messages/user-to-local-user.ts new file mode 100644 index 00000000..e8ca73f4 --- /dev/null +++ b/src/mailer/messages/user-to-local-user.ts @@ -0,0 +1,86 @@ +/** + * SudoSOS back-end API service. + * Copyright (C) 2024 Study association GEWIS + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * @license + */ + +/** + * This is the module page of the welcome-with-reset. + * + * @module internal/mailer + */ + +import MailMessage, { Language, MailLanguageMap } from '../mail-message'; +import MailContentBuilder from './mail-content-builder'; +import { ResetTokenInfo } from '../../service/authentication-service'; + +interface UserToLocalUserOptions { + email: string, + resetTokenInfo: ResetTokenInfo, + url?: string; +} + +const userToLocalUserDutch = new MailContentBuilder({ + getHTML: (context) => ` +

Je account is veranderd zodat je zonder GEWIS account kan inloggen.

+ +

Voordat je SudoSOS weer kunt gebruiken, dien je een wachtwoord te kiezen door te gaan naar ${context.url + '/passwordreset?token=' + context.resetTokenInfo.password + '&email=' + context.email}

+ +

Tot op de borrel!

`, + getSubject: 'Welkom bij SudoSOS!', + getTitle: 'Welkom!', + getText: (context) => ` +Je account is omgezet zodat je zonder GEWIS account kan inloggen. + +Voordat je SudoSOS weer kunt gebruiken, dien je een wachtwoord te kiezen door te gaan naar ${context.url + '/passwordreset?token=' + context.resetTokenInfo.password + '&email=' + context.email} + +Tot op de borrel!`, +}); + +const userToLocalUserEnglish = new MailContentBuilder({ + getHTML: (context) => ` +

Your account has changed such that you can login without having a GEWIS account.

+ +

Before you can actually use SudoSOS again, you have to set a password by going to ${context.url + '/passwordreset?token=' + context.resetTokenInfo.password + '&email=' + context.email}

+ +

See you on the borrel!

`, + getSubject: 'Welcome to SudoSOS!', + getTitle: 'Welcome!', + getText: (context) => ` +Your account has changed such that you can login without having a GEWIS account. + +Before you can actually use SudoSOS again, you have to set a password by going to ${context.url + '/passwordreset?token=' + context.resetTokenInfo.password + '&email=' + context.email} + +See you on the borrel!`, +}); + +const mailContents: MailLanguageMap = { + [Language.DUTCH]: userToLocalUserDutch, + [Language.ENGLISH]: userToLocalUserEnglish, +}; + +export default class UserToLocalUser extends MailMessage { + public constructor(options: UserToLocalUserOptions) { + const opt: UserToLocalUserOptions = { + ...options, + }; + if (!options.url) { + opt.url = process.env.url; + } + super(opt, mailContents); + } +} diff --git a/src/service/authentication-service.ts b/src/service/authentication-service.ts index 8d0d5338..a3956640 100644 --- a/src/service/authentication-service.ts +++ b/src/service/authentication-service.ts @@ -37,9 +37,7 @@ import TokenHandler from '../authentication/token-handler'; import RoleManager from '../rbac/role-manager'; import LDAPAuthenticator from '../entity/authenticator/ldap-authenticator'; import MemberAuthenticator from '../entity/authenticator/member-authenticator'; -import { - bindUser, getLDAPConnection, getLDAPSettings, LDAPResult, LDAPUser, userFromLDAP, -} from '../helpers/ad'; +import { bindUser, getLDAPConnection, getLDAPSettings, LDAPResult, LDAPUser, userFromLDAP } from '../helpers/ad'; import { parseUserToResponse } from '../helpers/revision-to-response'; import HashBasedAuthenticationMethod from '../entity/authenticator/hash-based-authentication-method'; import ResetToken from '../entity/authenticator/reset-token'; @@ -49,6 +47,7 @@ import NfcAuthenticator from '../entity/authenticator/nfc-authenticator'; import RBACService from './rbac-service'; import Role from '../entity/rbac/role'; import WithManager from '../database/with-manager'; +import UserService from './user-service'; export interface AuthenticationContext { tokenHandler: TokenHandler, @@ -308,7 +307,13 @@ export default class AuthenticationService extends WithManager { const authenticator = await this.manager.findOne(LDAPAuthenticator, { where: { UUID: ADUser.objectGUID }, relations: ['user'] }); // If there is no user associated with the GUID we create the user and bind it. - if (authenticator) return authenticator.user; + if (authenticator) { + if (authenticator.user.type == UserType.LOCAL_USER) { + await UserService.updateUser(authenticator.user.id, { canGoIntoDebt: true, type: UserType.MEMBER }); + } + + return authenticator.user; + } return onNewUser(ADUser); } diff --git a/src/service/user-service.ts b/src/service/user-service.ts index 602b4482..3abbce9a 100644 --- a/src/service/user-service.ts +++ b/src/service/user-service.ts @@ -47,6 +47,7 @@ import WelcomeWithReset from '../mailer/messages/welcome-with-reset'; import { Brackets, In } from 'typeorm'; import BalanceService from './balance-service'; import AssignedRole from '../entity/rbac/assigned-role'; +import UserToLocalUser from '../mailer/messages/user-to-local-user'; /** * Parameters used to filter on Get Users functions. @@ -291,6 +292,22 @@ export default class UserService { return true; } + /** + * Change user to local User + * @param userId - ID of the user to change to local user + */ + public static async changeToLocalUsers(userId: number): Promise { + const user = await User.findOne({ where: { id: userId } }); + if (!user) return undefined; + + const resetTokenInfo = await new AuthenticationService().createResetToken(user); + Mailer.getInstance().send(user, new UserToLocalUser({ email: user.email, resetTokenInfo })).then().catch((e) => { + throw e; + }); + + return UserService.updateUser(userId, { canGoIntoDebt: false, type: UserType.LOCAL_USER }); + } + /** * Combined query to return a users transfers and transactions from the database * @param user - The user of which to get. diff --git a/test/unit/controller/user-controller.ts b/test/unit/controller/user-controller.ts index cc8fe366..89b2e07c 100644 --- a/test/unit/controller/user-controller.ts +++ b/test/unit/controller/user-controller.ts @@ -2592,4 +2592,42 @@ describe('UserController', (): void => { }); }); }); + describe('POST /users/{id}/toLocalUser', ()=> { + it('should return 200 and a correct user response', async () => { + const id = 1; + const user = await User.findOne({ where: { id: id } }); + expect(user).to.not.be.null; + + const res = await request(ctx.app) + .post(`/users/${id}/toLocalUser`) + .set('Authorization', `Bearer ${ctx.adminToken}`); + + expect(res.status).to.be.equal(200); + expect((res.body as UserResponse).type).to.be.equal(UserType.LOCAL_USER); + expect((res.body as UserResponse).canGoIntoDebt).to.be.false; + }); + it('should return 403 if not admin', async () => { + const id = 1; + const user = await User.findOne({ where: { id: id } }); + expect(user).to.not.be.null; + + const res = await request(ctx.app) + .post(`/users/${id}/toLocalUser`) + .set('Authorization', `Bearer ${ctx.userToken}`); + + expect(res.status).to.be.equal(403); + }); + it('should return 404 if user does not exist', async () => { + const count = await User.count(); + const id = count + 1; + const user = await User.findOne({ where: { id } }); + expect(user).to.be.null; + + const res = await request(ctx.app) + .post(`/users/${id}/toLocalUser`) + .set('Authorization', `Bearer ${ctx.adminToken}`); + + expect(res.status).to.be.equal(404); + }); + }); }); diff --git a/test/unit/service/authentication-service.ts b/test/unit/service/authentication-service.ts index 3da0c2c4..ec3000dd 100644 --- a/test/unit/service/authentication-service.ts +++ b/test/unit/service/authentication-service.ts @@ -38,6 +38,7 @@ import HashBasedAuthenticationMethod from '../../../src/entity/authenticator/has import LocalAuthenticator from '../../../src/entity/authenticator/local-authenticator'; import AuthenticationResetTokenRequest from '../../../src/controller/request/authentication-reset-token-request'; import { truncateAllTables } from '../../setup'; +import UserService from '../../../src/service/user-service'; export default function userIsAsExpected(user: User | UserResponse, ADResponse: any) { expect(user.firstName).to.equal(ADResponse.givenName); @@ -187,6 +188,45 @@ describe('AuthenticationService', (): void => { expect(count).to.be.equal(await User.count()); }); + it('should login and set user to member again if member can be found in AD', async () => { + const otherValidADUser = { + ...ctx.validADUser, givenName: 'Test', objectGUID: Buffer.from('22', 'hex'), sAMAccountName: 'm0041', + }; + + const clientBindStub = sinon.stub(Client.prototype, 'bind').resolves(null); + const clientSearchStub = sinon.stub(Client.prototype, 'search').resolves({ + searchReferences: [], + searchEntries: [otherValidADUser], + }); + stubs.push(clientBindStub); + stubs.push(clientSearchStub); + + let user: User; + await ctx.connection.transaction(async (manager) => { + const service = new AuthenticationService(manager); + user = await service.LDAPAuthentication('m0041', 'This Is Correct', service.createUserAndBind.bind(service)); + }); + + userIsAsExpected(user, otherValidADUser); + + await UserService.changeToLocalUsers(user.id); + expect((await User.findOne({ where: { id: user.id } })).type).to.be.equal(UserType.LOCAL_USER); + + let DBUser = await User.findOne( + { where: { firstName: otherValidADUser.givenName, lastName: otherValidADUser.sn } }, + ); + expect(DBUser).to.not.be.undefined; + + const count = await User.count(); + + await ctx.connection.transaction(async (manager) => { + const service = new AuthenticationService(manager); + user = await service.LDAPAuthentication('m0041', 'This Is Correct', service.createUserAndBind.bind(service)); + }); + + expect(count).to.be.equal(await User.count()); + expect((await User.findOne({ where: { id: user.id } })).type).to.be.equal(UserType.MEMBER); + }); it('should return undefined if wrong password', async () => { const clientBindStub = sinon.stub(Client.prototype, 'bind').resolves(null); const clientSearchStub = sinon.stub(Client.prototype, 'search').resolves({