Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion api/v1/controllers/apikeys.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import * as JV from '../../../lib/jsonvalidator.mjs'
import * as Crypt from '../../../lib/crypt.mjs'
import * as KMS from '../../../lib/kms/kms.mjs'
import * as User from '../../../model/user.mjs'
import * as APIKey from '../../../model/apikey.mjs'

import { isAdmin, isReadOnly } from '../../../lib/auth.mjs'
import DB from '../../../lib/db.mjs'
Expand Down Expand Up @@ -113,6 +114,11 @@ export async function create (req, res, next) {
return
}

if (req.body.ipwhitelist && !APIKey.validateCIDRList(req.body.ipwhitelist)) {
res.status(R.BAD_REQUEST).send(R.badRequest('Invalid IP whitelist format'))
return
}

// Creates the API key
const secret = Crypt.randomString(20)
const encsecret = await KMS.encrypt(secret, 'aes-256-gcm')
Expand All @@ -129,7 +135,8 @@ export async function create (req, res, next) {
description: req.body.description,
userid: req.body.userid,
expiresat: req.body.expiresat + 'T00:00:00.000Z',
active: req.body.active
active: req.body.active,
ipwhitelist: req.body.ipwhitelist || null
}
})

Expand Down Expand Up @@ -188,6 +195,15 @@ export async function update (req, res, next) {
if (Object.prototype.hasOwnProperty.call(req.body, 'active')) {
updateStruct.active = req.body.active
}
if (Object.prototype.hasOwnProperty.call(req.body, 'ipwhitelist')) {
// Validate IP whitelist
if (!APIKey.validateCIDRList(req.body.ipwhitelist)) {
res.status(R.BAD_REQUEST).send(R.badRequest('Invalid IP whitelist format'))
return
}

updateStruct.ipwhitelist = req.body.ipwhitelist || null
}

await DB.apikeys.update({
data: updateStruct,
Expand Down
14 changes: 12 additions & 2 deletions api/v1/controllers/login.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,17 +56,27 @@ export async function login (req, res, next) {
return
}

// Search the API key anc check if it is active
// Search the API key and check if it is active
const apik = await DB.apikeys.findUnique({
where: { id: data.apikey },
select: { userid: true, active: true }
select: { userid: true, active: true, ipwhitelist: true }
})
if (!apik.active) {
Events.add(data.username, Const.EV_ACTION_LOGIN_APIKEY_NOTVALID, Const.EV_ENTITY_APIKEY, data.apikey)
res.status(R.UNAUTHORIZED).send(R.ko('API key not valid'))
return
}

// Check if IP is whitelisted
if (apik.ipwhitelist) {
const clientIp = req.connection.remoteAddress
if (!ApiKey.checkIPWhitelist(apik.ipwhitelist, clientIp)) {
Events.add(data.username, Const.EV_ACTION_LOGIN_APIKEY_IPNOTWHITELISTED, Const.EV_ENTITY_APIKEY, data.apikey)
res.status(R.UNAUTHORIZED).send(R.ko('API key not valid'))
return
}
}

// Get corresponding user
const user = await DB.users.findUnique({
where: { id: apik.userid },
Expand Down
4 changes: 2 additions & 2 deletions docs/apidoc/paths/apikeys.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ post:
tags:
- API keys
operationId: "createAPIKey"
summary: Create API key
description: Create API key
summary: Create an API key
description: Create an API key
requestBody:
content:
application/json:
Expand Down
14 changes: 14 additions & 0 deletions docs/apidoc/requestbodies/apikeys.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,22 @@ apikeyCreateBody:
description:
type: string
description: Description
example: 'API key for automated client'
userid:
type: string
description: Connected user
example: 'user_123'
expiresat:
type: string
description: Expiring date, YYYY-MM-DD
example: '2023-12-31'
active:
type: boolean
description: Active status
ipwhitelist:
type: string
description: Comma-separated list of IPs or CIDR ranges
example: '192.168.1.1/32,192.168.2.0/24'
required:
- description
- userid
Expand All @@ -27,12 +34,19 @@ apikeyUpdateBody:
description:
type: string
description: Description
example: 'Updated API key description'
userid:
type: string
description: Connected user
example: 'user_123'
expiresat:
type: string
description: Expiring date, YYYY-MM-DD
example: '2023-12-31'
active:
type: boolean
description: Active status
ipwhitelist:
type: string
description: Comma-separated list of IPs or CIDR ranges
example: '192.168.1.1/32,192.168.2.0/24'
10 changes: 9 additions & 1 deletion docs/apidoc/responsebodies/apikeys.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,17 @@ getsuccess:
description: Description
userid:
type: string
description: Connected user
description: Connected user ID
expiresat:
type: string
description: Expiring date
active:
type: boolean
description: Active status
ipwhitelist:
type: string
description: Comma-separated list of IPs or CIDR ranges
example: '192.168.1.1/32,192.168.2.0/24'
createdat:
type: string
description: Creation time
Expand Down Expand Up @@ -70,6 +74,10 @@ listsuccess:
active:
type: boolean
description: Active status
ipwhitelist:
type: string
description: Comma-separated list of IPs or CIDR ranges
example: '192.168.1.1/32,192.168.2.0/24'
createdat:
type: string
description: Creation time
Expand Down
4 changes: 3 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ This are the software you need to have in order to run PassWeaver API:
These are the features this API support, in random order:

- Cloud KMS integration (currently, only Google Cloud KMS)
- API keys for software consumers
- API keys, with IP whitelist
- Personal folders for each user
- Favorite items
- Share one-time secrets with anyone, even if they have not an account
Expand Down Expand Up @@ -122,6 +122,8 @@ You can share both random text, or an entire item.
API keys can be created to easier credential handling in case of automated clients. An API key is bound to a user, whose authentication method must be 'apikey': this way you can easily manage permissions as you would do for a regular
user (assigning it to a group), without the need of exposing users password or to disrupt functionalities in case the user changes it.

For each API key you can optionally define a whitelist of CIDR to restrict access.

You can create as many API keys you need for a given user and activate/disactivate them at any time. They also have an expiration date.

## Authentication
Expand Down
4 changes: 3 additions & 1 deletion lib/const.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ export const EV_ACTION_LOGINFAILED = 63
export const EV_ACTION_LOGIN_APIKEY = 64
export const EV_ACTION_LOGIN_APIKEY_NOTFOUND = 65
export const EV_ACTION_LOGIN_APIKEY_NOTVALID = 66
export const EV_ACTION_LOGIN_APIKEY_FAILED = 66
export const EV_ACTION_LOGIN_APIKEY_FAILED = 67
export const EV_ACTION_LOGIN_APIKEY_IPNOTWHITELISTED = 68
export const EV_ACTION_UNLOCKNF = 70
export const EV_ACTION_UNLOCKNV = 71
export const EV_ACTION_UNLOCK = 72
Expand Down Expand Up @@ -82,6 +83,7 @@ export const actionDescriptions = {
65: 'API key not found',
66: 'API key not valid',
67: 'API key bad secret',
68: 'API key IP not whitelisted',
70: 'Personal folder password not set',
71: 'Personal folder password mismatch',
72: 'Personal folder unlocked',
Expand Down
3 changes: 2 additions & 1 deletion lib/schemas/apikey_create.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"description" : { "type": "string", "maxLength": 100 },
"userid": { "type": "string", "maxLength": 50},
"expiresat" : { "type": "string" },
"active": { "type": "boolean" }
"active": { "type": "boolean" },
"ipwhitelist": { "type": "string", "maxLength": 500, "nullable": true }
},
"required": ["description", "userid", "expiresat", "active"]
}
33 changes: 33 additions & 0 deletions model/apikey.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

import DB from '../lib/db.mjs'
import * as KMS from '../lib/kms/kms.mjs'
import isInSubnet from 'is-in-subnet'
import isCIDR from 'is-cidr'

/**
* Check if API key exists
Expand Down Expand Up @@ -41,3 +43,34 @@ export async function checkSecret (apikey, secret) {
const dec = await KMS.decrypt(apik.kmsid, apik.dek, apik.secret, apik.secretiv, apik.secretauthtag, apik.algorithm)
return (dec === secret)
}

/**
* Validate string as a valid list of CIDR ranges
* @param {string} cidrlist Comma-separated list of IPs or CIDR ranges
* @returns {boolean} True if valid, false otherwise
*/
export function validateCIDRList (cidrlist) {
if (!cidrlist) return true // Empty list is valid

const cidrs = cidrlist.split(',')
for (const cidr of cidrs) {
const tcdr = cidr.trim()
if (!isCIDR(tcdr)) {
return false
}
}
return true
}

/**
* Check if IP is contained in the whitelist of CIDR ranges
* @param {string} cidrlist Comma-separated list of CIDR ranges
* @param {string} ip IP address to check
* @returns {boolean} True if IP is whitelisted, false otherwise
*/
export function checkIPWhitelist (cidrlist, ip) {
if (!cidrlist) return true

const cidrs = cidrlist.split(',')
return isInSubnet.isInSubnet(ip, cidrs)
}
57 changes: 52 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@
"prisma": "6.13.0",
"redis": "5.6.1",
"rotating-file-stream": "3.2.6",
"uuidv7": "1.0.2"
"uuidv7": "1.0.2",
"is-in-subnet": "4.0.1",
"is-cidr": "6.0.0"
},
"prisma": {
"seed": "node prisma/seed.cjs"
Expand Down
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ model apikeys {
userid String
expiresat DateTime
active Boolean
ipwhitelist String?

createdat DateTime @default(now())
updatedat DateTime @updatedAt
Expand Down
Loading