Skip to content

Commit f9e5e9d

Browse files
committed
feat: enforce SSH key uniqueness to prevent duplicate keys across users
1 parent 7a6b7a7 commit f9e5e9d

File tree

6 files changed

+244
-3
lines changed

6 files changed

+244
-3
lines changed

src/db/file/users.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from 'fs';
22
import Datastore from '@seald-io/nedb';
33

44
import { User, UserQuery } from '../types';
5+
import { DuplicateSSHKeyError, UserNotFoundError } from '../../errors/DatabaseErrors';
56

67
const COMPACTION_INTERVAL = 1000 * 60 * 60 * 24; // once per day
78

@@ -182,10 +183,20 @@ export const getUsers = (query: Partial<UserQuery> = {}): Promise<User[]> => {
182183

183184
export const addPublicKey = (username: string, publicKey: string): Promise<void> => {
184185
return new Promise((resolve, reject) => {
185-
findUser(username)
186+
// Check if this key already exists for any user
187+
findUserBySSHKey(publicKey)
188+
.then((existingUser) => {
189+
if (existingUser && existingUser.username.toLowerCase() !== username.toLowerCase()) {
190+
reject(new DuplicateSSHKeyError(existingUser.username));
191+
return;
192+
}
193+
194+
// Key doesn't exist for other users
195+
return findUser(username);
196+
})
186197
.then((user) => {
187198
if (!user) {
188-
reject(new Error('User not found'));
199+
reject(new UserNotFoundError(username));
189200
return;
190201
}
191202
if (!user.publicKeys) {

src/db/mongo/users.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { toClass } from '../helper';
33
import { User } from '../types';
44
import { connect } from './helper';
55
import _ from 'lodash';
6+
import { DuplicateSSHKeyError } from '../../errors/DatabaseErrors';
67
const collectionName = 'users';
78

89
export const findUser = async function (username: string): Promise<User | null> {
@@ -71,6 +72,14 @@ export const updateUser = async (user: Partial<User>): Promise<void> => {
7172
};
7273

7374
export const addPublicKey = async (username: string, publicKey: string): Promise<void> => {
75+
// Check if this key already exists for any user
76+
const existingUser = await findUserBySSHKey(publicKey);
77+
78+
if (existingUser && existingUser.username.toLowerCase() !== username.toLowerCase()) {
79+
throw new DuplicateSSHKeyError(existingUser.username);
80+
}
81+
82+
// Key doesn't exist for other users
7483
const collection = await connect(collectionName);
7584
await collection.updateOne(
7685
{ username: username.toLowerCase() },

src/errors/DatabaseErrors.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Custom error classes for database operations
3+
* These provide type-safe error handling and better maintainability
4+
*/
5+
6+
/**
7+
* Thrown when attempting to add an SSH key that is already in use by another user
8+
*/
9+
export class DuplicateSSHKeyError extends Error {
10+
constructor(public readonly existingUsername: string) {
11+
super(`SSH key already in use by user '${existingUsername}'`);
12+
this.name = 'DuplicateSSHKeyError';
13+
Object.setPrototypeOf(this, DuplicateSSHKeyError.prototype);
14+
}
15+
}
16+
17+
/**
18+
* Thrown when a user is not found in the database
19+
*/
20+
export class UserNotFoundError extends Error {
21+
constructor(public readonly username: string) {
22+
super(`User not found`);
23+
this.name = 'UserNotFoundError';
24+
Object.setPrototypeOf(this, UserNotFoundError.prototype);
25+
}
26+
}

src/service/routes/users.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { utils } from 'ssh2';
33

44
import * as db from '../../db';
55
import { toPublicUser } from './publicApi';
6+
import { DuplicateSSHKeyError, UserNotFoundError } from '../../errors/DatabaseErrors';
67

78
const router = express.Router();
89
const parseKey = utils.parseKey;
@@ -65,7 +66,19 @@ router.post('/:username/ssh-keys', async (req: Request, res: Response) => {
6566
res.status(201).json({ message: 'SSH key added successfully' });
6667
} catch (error) {
6768
console.error('Error adding SSH key:', error);
68-
res.status(500).json({ error: 'Failed to add SSH key' });
69+
70+
if (error instanceof DuplicateSSHKeyError) {
71+
res.status(409).json({ error: error.message });
72+
return;
73+
}
74+
75+
if (error instanceof UserNotFoundError) {
76+
res.status(404).json({ error: error.message });
77+
return;
78+
}
79+
80+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
81+
res.status(500).json({ error: `Failed to add SSH key: ${errorMessage}` });
6982
}
7083
});
7184

test/services/routes/users.test.js

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ const chai = require('chai');
22
const chaiHttp = require('chai-http');
33
const sinon = require('sinon');
44
const express = require('express');
5+
const fs = require('fs');
6+
const path = require('path');
57
const usersRouter = require('../../../src/service/routes/users').default;
68
const db = require('../../../src/db');
9+
const { DuplicateSSHKeyError, UserNotFoundError } = require('../../../src/errors/DatabaseErrors');
710

811
const { expect } = chai;
912
chai.use(chaiHttp);
@@ -64,4 +67,89 @@ describe('Users API', function () {
6467
admin: false,
6568
});
6669
});
70+
71+
describe('POST /users/:username/ssh-keys', function () {
72+
let authenticatedApp;
73+
const validPublicKey = fs
74+
.readFileSync(path.join(__dirname, '../../.ssh/host_key.pub'), 'utf8')
75+
.trim();
76+
77+
before(function () {
78+
authenticatedApp = express();
79+
authenticatedApp.use(express.json());
80+
authenticatedApp.use((req, res, next) => {
81+
req.user = { username: 'alice', admin: true };
82+
next();
83+
});
84+
authenticatedApp.use('/users', usersRouter);
85+
});
86+
87+
it('should return 409 when SSH key is already used by another user', async function () {
88+
const publicKey = validPublicKey;
89+
90+
sinon.stub(db, 'addPublicKey').rejects(new DuplicateSSHKeyError('bob'));
91+
92+
const res = await chai
93+
.request(authenticatedApp)
94+
.post('/users/alice/ssh-keys')
95+
.send({ publicKey });
96+
97+
expect(res).to.have.status(409);
98+
expect(res.body).to.have.property('error');
99+
expect(res.body.error).to.include("already in use by user 'bob'");
100+
});
101+
102+
it('should return 404 when user not found', async function () {
103+
const publicKey = validPublicKey;
104+
105+
sinon.stub(db, 'addPublicKey').rejects(new UserNotFoundError('nonexistent'));
106+
107+
const res = await chai
108+
.request(authenticatedApp)
109+
.post('/users/nonexistent/ssh-keys')
110+
.send({ publicKey });
111+
112+
expect(res).to.have.status(404);
113+
expect(res.body).to.have.property('error');
114+
expect(res.body.error).to.include('User not found');
115+
});
116+
117+
it('should return 201 when SSH key is added successfully', async function () {
118+
const publicKey = validPublicKey;
119+
120+
sinon.stub(db, 'addPublicKey').resolves();
121+
122+
const res = await chai
123+
.request(authenticatedApp)
124+
.post('/users/alice/ssh-keys')
125+
.send({ publicKey });
126+
127+
expect(res).to.have.status(201);
128+
expect(res.body).to.have.property('message');
129+
expect(res.body.message).to.equal('SSH key added successfully');
130+
});
131+
132+
it('should return 400 when public key is missing', async function () {
133+
const res = await chai.request(authenticatedApp).post('/users/alice/ssh-keys').send({});
134+
135+
expect(res).to.have.status(400);
136+
expect(res.body).to.have.property('error');
137+
expect(res.body.error).to.include('Public key is required');
138+
});
139+
140+
it('should return 500 for unexpected errors', async function () {
141+
const publicKey = validPublicKey;
142+
143+
sinon.stub(db, 'addPublicKey').rejects(new Error('Database connection failed'));
144+
145+
const res = await chai
146+
.request(authenticatedApp)
147+
.post('/users/alice/ssh-keys')
148+
.send({ publicKey });
149+
150+
expect(res).to.have.status(500);
151+
expect(res.body).to.have.property('error');
152+
expect(res.body.error).to.include('Failed to add SSH key');
153+
});
154+
});
67155
});

test/testDb.test.js

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,100 @@ describe('Database clients', async () => {
574574
// leave user in place for next test(s)
575575
});
576576

577+
it('should be able to add a public SSH key to a user', async function () {
578+
const testKey = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC [email protected]';
579+
580+
await db.addPublicKey(TEST_USER.username, testKey);
581+
582+
const user = await db.findUser(TEST_USER.username);
583+
expect(user.publicKeys).to.include(testKey);
584+
});
585+
586+
it('should not add duplicate SSH key to same user', async function () {
587+
const testKey = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC [email protected]';
588+
589+
// Add same key again - should not throw error but also not duplicate
590+
await db.addPublicKey(TEST_USER.username, testKey);
591+
592+
const user = await db.findUser(TEST_USER.username);
593+
const keyCount = user.publicKeys.filter((k) => k === testKey).length;
594+
expect(keyCount).to.equal(1);
595+
});
596+
597+
it('should throw DuplicateSSHKeyError when adding key already used by another user', async function () {
598+
const testKey = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC [email protected]';
599+
const otherUser = {
600+
username: 'other-user',
601+
password: 'password',
602+
603+
gitAccount: 'other-git',
604+
admin: false,
605+
publicKeys: [],
606+
};
607+
608+
// Create another user
609+
await db.createUser(
610+
otherUser.username,
611+
otherUser.password,
612+
otherUser.email,
613+
otherUser.gitAccount,
614+
otherUser.admin,
615+
);
616+
617+
let threwError = false;
618+
let errorType = null;
619+
try {
620+
// Try to add the same key to another user
621+
await db.addPublicKey(otherUser.username, testKey);
622+
} catch (e) {
623+
threwError = true;
624+
errorType = e.constructor.name;
625+
}
626+
627+
expect(threwError).to.be.true;
628+
expect(errorType).to.equal('DuplicateSSHKeyError');
629+
630+
// Cleanup
631+
await db.deleteUser(otherUser.username);
632+
});
633+
634+
it('should be able to find user by SSH key', async function () {
635+
const testKey = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC [email protected]';
636+
637+
const user = await db.findUserBySSHKey(testKey);
638+
expect(user).to.not.be.null;
639+
expect(user.username).to.equal(TEST_USER.username);
640+
});
641+
642+
it('should return null when finding user by non-existent SSH key', async function () {
643+
const nonExistentKey = 'ssh-rsa NONEXISTENT';
644+
645+
const user = await db.findUserBySSHKey(nonExistentKey);
646+
expect(user).to.be.null;
647+
});
648+
649+
it('should be able to remove a public SSH key from a user', async function () {
650+
const testKey = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC [email protected]';
651+
652+
await db.removePublicKey(TEST_USER.username, testKey);
653+
654+
const user = await db.findUser(TEST_USER.username);
655+
expect(user.publicKeys).to.not.include(testKey);
656+
});
657+
658+
it('should not throw error when removing non-existent SSH key', async function () {
659+
const nonExistentKey = 'ssh-rsa NONEXISTENT';
660+
661+
let threwError = false;
662+
try {
663+
await db.removePublicKey(TEST_USER.username, nonExistentKey);
664+
} catch (e) {
665+
threwError = true;
666+
}
667+
668+
expect(threwError).to.be.false;
669+
});
670+
577671
it('should throw an error when authorising a user to push on non-existent repo', async function () {
578672
let threwError = false;
579673
try {

0 commit comments

Comments
 (0)