Implemented password hashing. Added and improved tests.
This commit is contained in:
parent
b5a70f6de0
commit
52b7d2f48b
@ -3,6 +3,8 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"artifactsRepo": { "type": "string" },
|
"artifactsRepo": { "type": "string" },
|
||||||
|
"authSecret": { "type": "string" },
|
||||||
|
"pwdCost": { "type": "integer" },
|
||||||
"projects": {
|
"projects": {
|
||||||
"title": "ProjectsList",
|
"title": "ProjectsList",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
@ -30,19 +32,14 @@
|
|||||||
"title": "UserDefinition",
|
"title": "UserDefinition",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"username": { "type": "string" },
|
"name": { "type": "string" },
|
||||||
"hashedPwd": { "type": "string" }
|
"hashedPwd": { "type": "string" }
|
||||||
},
|
},
|
||||||
"required": ["username", "hashedPwd"],
|
"required": ["name", "hashedPwd"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"tokens": {
|
|
||||||
"title": "TokensList",
|
|
||||||
"type": "array",
|
|
||||||
"items": { "type": "string" }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["artifactsRepo", "projects", "users", "tokens"],
|
"required": ["artifactsRepo", "authSecret", "pwdCost", "projects", "users"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
|
@ -25,6 +25,7 @@ when isMainModule:
|
|||||||
Usage:
|
Usage:
|
||||||
strawboss serve
|
strawboss serve
|
||||||
strawboss run <project> <step> [options]
|
strawboss run <project> <step> [options]
|
||||||
|
strawboss hashpwd <pwd>
|
||||||
|
|
||||||
Options
|
Options
|
||||||
|
|
||||||
@ -40,7 +41,6 @@ Options
|
|||||||
|
|
||||||
let args = docopt(doc, version = "strawboss v" & SB_VER)
|
let args = docopt(doc, version = "strawboss v" & SB_VER)
|
||||||
|
|
||||||
echo $args
|
|
||||||
if args["run"]:
|
if args["run"]:
|
||||||
|
|
||||||
let req = RunRequest(
|
let req = RunRequest(
|
||||||
@ -62,3 +62,9 @@ Options
|
|||||||
|
|
||||||
elif args["serve"]: server.start(cfg)
|
elif args["serve"]: server.start(cfg)
|
||||||
|
|
||||||
|
elif args["hashpwd"]:
|
||||||
|
echo $cfg.pwdCost
|
||||||
|
let pwd = server.hashPwd($args["<pwd>"], cfg.pwdCost)
|
||||||
|
echo pwd
|
||||||
|
echo pwd[0..28]
|
||||||
|
|
||||||
|
@ -35,6 +35,7 @@ type
|
|||||||
artifactsRepo*: string
|
artifactsRepo*: string
|
||||||
authSecret*: string
|
authSecret*: string
|
||||||
projects*: seq[ProjectDef]
|
projects*: seq[ProjectDef]
|
||||||
|
pwdCost*: int8
|
||||||
users*: seq[UserRef]
|
users*: seq[UserRef]
|
||||||
|
|
||||||
# Equality on custom types
|
# Equality on custom types
|
||||||
@ -62,7 +63,7 @@ proc getIfExists(n: JsonNode, key: string): JsonNode =
|
|||||||
|
|
||||||
proc getOrFail(n: JsonNode, key: string, objName: string = ""): JsonNode =
|
proc getOrFail(n: JsonNode, key: string, objName: string = ""): JsonNode =
|
||||||
# convenience method to get a key from a JObject or raise an exception
|
# convenience method to get a key from a JObject or raise an exception
|
||||||
if not n.hasKey(key): raiseEx objName & " missing key " & key
|
if not n.hasKey(key): raiseEx objName & " missing key '" & key & "'"
|
||||||
return n[key]
|
return n[key]
|
||||||
|
|
||||||
# Configuration parsing code
|
# Configuration parsing code
|
||||||
@ -97,6 +98,7 @@ proc loadStrawBossConfig*(cfgFile: string): StrawBossConfig =
|
|||||||
result = StrawBossConfig(
|
result = StrawBossConfig(
|
||||||
artifactsRepo: jsonCfg.getIfExists("artifactsRepo").getStr("artifacts"),
|
artifactsRepo: jsonCfg.getIfExists("artifactsRepo").getStr("artifacts"),
|
||||||
authSecret: jsonCfg.getOrFail("authSecret", "strawboss config").getStr,
|
authSecret: jsonCfg.getOrFail("authSecret", "strawboss config").getStr,
|
||||||
|
pwdCost: int8(jsonCfg.getOrFail("pwdCost", "strawboss config").getNum),
|
||||||
projects: projectDefs,
|
projects: projectDefs,
|
||||||
users: users)
|
users: users)
|
||||||
|
|
||||||
@ -170,8 +172,7 @@ proc `%`*(req: RunRequest): JsonNode =
|
|||||||
"stepName": req.stepName,
|
"stepName": req.stepName,
|
||||||
"buildRef": req.buildRef,
|
"buildRef": req.buildRef,
|
||||||
"workspaceDir": req.workspaceDir,
|
"workspaceDir": req.workspaceDir,
|
||||||
"forceRebuild": req.forceRebuild
|
"forceRebuild": req.forceRebuild }
|
||||||
}
|
|
||||||
|
|
||||||
proc `$`*(s: BuildStatus): string = result = pretty(%s)
|
proc `$`*(s: BuildStatus): string = result = pretty(%s)
|
||||||
proc `$`*(req: RunRequest): string = result = pretty(%req)
|
proc `$`*(req: RunRequest): string = result = pretty(%req)
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import asyncdispatch, bcrypt, jester, json, jwt, osproc, sequtils, tempfile, times
|
import asyncdispatch, bcrypt, jester, json, jwt, osproc, sequtils, tempfile,
|
||||||
|
times, unittest
|
||||||
|
|
||||||
import ./configuration, ./core, private/util
|
import ./configuration, ./core, private/util
|
||||||
|
|
||||||
@ -12,10 +13,9 @@ type Worker = object
|
|||||||
type
|
type
|
||||||
Session = object
|
Session = object
|
||||||
user*: UserRef
|
user*: UserRef
|
||||||
issuedAt*, expires*: TimeInfo
|
issuedAt*, expires*: Time
|
||||||
|
|
||||||
const ISO_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss"
|
const ISO_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss"
|
||||||
const BCRYPT_ROUNDS = 16
|
|
||||||
|
|
||||||
proc makeJsonResp(status: HttpCode, details: string = ""): string =
|
proc makeJsonResp(status: HttpCode, details: string = ""): string =
|
||||||
result = $(%* {
|
result = $(%* {
|
||||||
@ -24,28 +24,34 @@ proc makeJsonResp(status: HttpCode, details: string = ""): string =
|
|||||||
"details": details
|
"details": details
|
||||||
})
|
})
|
||||||
|
|
||||||
proc newSession(user: UserRef): Session =
|
proc newSession*(user: UserRef): Session =
|
||||||
result = Session(
|
result = Session(
|
||||||
user: user,
|
user: user,
|
||||||
issuedAt: getGMTime(getTime()),
|
issuedAt: getTime(),
|
||||||
expires: daysForward(7))
|
expires: daysForward(7).toTime())
|
||||||
|
|
||||||
proc toJWT(session: Session): JWT =
|
proc toJWT*(cfg: StrawBossConfig, session: Session): string =
|
||||||
result = JWT(
|
# result = toJWT(%* {
|
||||||
|
# "header": {
|
||||||
|
# "alg": "HS256",
|
||||||
|
# "typ": "JWT" },
|
||||||
|
# "claims": {
|
||||||
|
# "sub": session.user.name,
|
||||||
|
# "iat": session.issuedAt.toSeconds().int,
|
||||||
|
# "exp": session.expires.toSeconds().int } })
|
||||||
|
|
||||||
|
var jwt = JWT(
|
||||||
header: JOSEHeader(alg: HS256, typ: "jwt"),
|
header: JOSEHeader(alg: HS256, typ: "jwt"),
|
||||||
claims: toClaims(%*{
|
claims: toClaims(%*{
|
||||||
"sub": session.user.name,
|
"sub": session.user.name,
|
||||||
"iss": session.issuedAt.format(ISO_TIME_FORMAT),
|
"iat": session.issuedAt.toSeconds().int,
|
||||||
"exp": session.expires.format(ISO_TIME_FORMAT) }))
|
"exp": session.expires.toSeconds().int }))
|
||||||
|
|
||||||
proc extractSession(cfg: StrawBossConfig, request: Request): Session =
|
jwt.sign(cfg.authSecret)
|
||||||
|
result = $jwt
|
||||||
|
|
||||||
# Find the auth header
|
proc fromJWT*(cfg: StrawBossConfig, strTok: string): Session =
|
||||||
if not request.headers.hasKey("Authentication"):
|
let jwt = toJWT(strTok)
|
||||||
raiseEx "No auth token."
|
|
||||||
|
|
||||||
# Read and verify the JWT token
|
|
||||||
let jwt = toJWT(request.headers["Authentication"])
|
|
||||||
var secret = cfg.authSecret
|
var secret = cfg.authSecret
|
||||||
if not jwt.verify(secret): raiseEx "Unable to verify auth token."
|
if not jwt.verify(secret): raiseEx "Unable to verify auth token."
|
||||||
jwt.verifyTimeClaims()
|
jwt.verifyTimeClaims()
|
||||||
@ -57,13 +63,17 @@ proc extractSession(cfg: StrawBossConfig, request: Request): Session =
|
|||||||
|
|
||||||
result = Session(
|
result = Session(
|
||||||
user: users[0],
|
user: users[0],
|
||||||
issuedAt: parse(jwt.claims["iat"].node.str, ISO_TIME_FORMAT),
|
issuedAt: fromSeconds(jwt.claims["iat"].node.num),
|
||||||
expires: parse(jwt.claims["exp"].node.str, ISO_TIME_FORMAT))
|
expires: fromSeconds(jwt.claims["exp"].node.num))
|
||||||
|
|
||||||
template requireAuth() =
|
proc extractSession(cfg: StrawBossConfig, request: Request): Session =
|
||||||
var session {.inject.}: Session
|
|
||||||
try: session = extractSession(givenCfg, request)
|
# Find the auth header
|
||||||
except: resp(Http401, makeJsonResp(Http401), "application/json")
|
if not request.headers.hasKey("Authentication"):
|
||||||
|
raiseEx "No auth token."
|
||||||
|
|
||||||
|
# Read and verify the JWT token
|
||||||
|
result = fromJWT(cfg, request.headers["Authentication"])
|
||||||
|
|
||||||
proc spawnWorker(req: RunRequest): Worker =
|
proc spawnWorker(req: RunRequest): Worker =
|
||||||
let dir = mkdtemp()
|
let dir = mkdtemp()
|
||||||
@ -73,6 +83,32 @@ proc spawnWorker(req: RunRequest): Worker =
|
|||||||
process: startProcess("strawboss", ".", args, loadEnv(), {poUsePath}),
|
process: startProcess("strawboss", ".", args, loadEnv(), {poUsePath}),
|
||||||
workingDir: dir)
|
workingDir: dir)
|
||||||
|
|
||||||
|
proc hashPwd*(pwd: string, cost: int8): string =
|
||||||
|
let salt = genSalt(cost)
|
||||||
|
result = hash(pwd, salt)
|
||||||
|
|
||||||
|
proc validatePwd*(u: UserRef, givenPwd: string): bool =
|
||||||
|
let salt = u.hashedPwd[0..28] # TODO: magic numbers
|
||||||
|
result = compare(u.hashedPwd, hash(givenPwd, salt))
|
||||||
|
|
||||||
|
proc makeAuthToken*(cfg: StrawBossConfig, uname, pwd: string): string =
|
||||||
|
if uname == nil or pwd == nil:
|
||||||
|
raiseEx "fields 'username' and 'password' required"
|
||||||
|
|
||||||
|
# find the user record
|
||||||
|
let users = cfg.users.filterIt(it.name == uname)
|
||||||
|
if users.len != 1: raiseEx "invalid username or password"
|
||||||
|
|
||||||
|
let user = users[0]
|
||||||
|
|
||||||
|
if not validatePwd(user, pwd): raiseEx "invalid username or password"
|
||||||
|
result = toJWT(cfg, newSession(user))
|
||||||
|
|
||||||
|
template requireAuth() =
|
||||||
|
var session {.inject.}: Session
|
||||||
|
try: session = extractSession(givenCfg, request)
|
||||||
|
except: resp(Http401, makeJsonResp(Http401), "application/json")
|
||||||
|
|
||||||
proc start*(givenCfg: StrawBossConfig): void =
|
proc start*(givenCfg: StrawBossConfig): void =
|
||||||
|
|
||||||
var workers: seq[Worker] = @[]
|
var workers: seq[Worker] = @[]
|
||||||
@ -89,26 +125,9 @@ proc start*(givenCfg: StrawBossConfig): void =
|
|||||||
resp($(%(givenCfg.projects)), "application/json")
|
resp($(%(givenCfg.projects)), "application/json")
|
||||||
|
|
||||||
get "/api/auth-token":
|
get "/api/auth-token":
|
||||||
var username, pwd: string
|
|
||||||
try:
|
try:
|
||||||
username = @"username"
|
let authToken = makeAuthToken(givenCfg, @"username", @"password")
|
||||||
pwd = @"password"
|
except: resp(Http401, makeJsonResp(Http401, getCurrentExceptionMsg()))
|
||||||
except: resp(Http401, makeJsonResp(Http401, "fields 'username' and 'password' required"))
|
|
||||||
|
|
||||||
let users = givenCfg.users.filterIt(it.name == username)
|
|
||||||
if users.len != 1:
|
|
||||||
resp(Http401, makeJsonResp(Http401, "invalid username or password"))
|
|
||||||
|
|
||||||
let user = users[0]
|
|
||||||
|
|
||||||
# generate salt
|
|
||||||
let salt = genSalt(BCRYPT_ROUNDS)
|
|
||||||
|
|
||||||
# bcrypt
|
|
||||||
let hashedPwd = hash(pwd, salt)
|
|
||||||
stdout.writeLine "Hashed pwd is " & $hashedPwd
|
|
||||||
|
|
||||||
resp(Http501, makeJsonResp(Http501))
|
|
||||||
|
|
||||||
post "/api/project/@projectName/@stepName/run/@buildRef?":
|
post "/api/project/@projectName/@stepName/run/@buildRef?":
|
||||||
workers.add(spawnWorker(RunRequest(
|
workers.add(spawnWorker(RunRequest(
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"artifactsRepo": "artifacts",
|
"artifactsRepo": "artifacts",
|
||||||
|
"authSecret": "change me",
|
||||||
"users": [
|
"users": [
|
||||||
{ "name": "bob@builder.com", "hashedPwd": "testvalue" },
|
{ "name": "bob@builder.com", "hashedPwd": "testvalue" },
|
||||||
{ "name": "sam@sousa.com", "hashedPwd": "testvalue" }
|
{ "name": "sam@sousa.com", "hashedPwd": "testvalue" }
|
||||||
],
|
],
|
||||||
"authSecret": "change me",
|
"pwdCost": 11,
|
||||||
"projects": [
|
"projects": [
|
||||||
{ "name": "test-project-1",
|
{ "name": "test-project-1",
|
||||||
"repo": "/non-existent/dir",
|
"repo": "/non-existent/dir",
|
||||||
|
4
src/test/nim/runtests.nim
Normal file
4
src/test/nim/runtests.nim
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
import ./tserver.nim
|
||||||
|
import ./tconfiguration.nim
|
Binary file not shown.
@ -1,6 +1,6 @@
|
|||||||
import strtabs, tables, unittest
|
import strtabs, tables, unittest
|
||||||
import ./testutil
|
import ./testutil
|
||||||
import ../../../main/nim/strawbosspkg/configuration
|
import ../../main/nim/strawbosspkg/configuration
|
||||||
|
|
||||||
suite "load and save configuration objects":
|
suite "load and save configuration objects":
|
||||||
|
|
||||||
@ -23,6 +23,7 @@ suite "load and save configuration objects":
|
|||||||
check:
|
check:
|
||||||
cfg.artifactsRepo == "artifacts"
|
cfg.artifactsRepo == "artifacts"
|
||||||
cfg.authSecret == "change me"
|
cfg.authSecret == "change me"
|
||||||
|
cfg.pwdCost == 11
|
||||||
sameContents(expectedUsers, cfg.users)
|
sameContents(expectedUsers, cfg.users)
|
||||||
sameContents(expectedProjects, cfg.projects)
|
sameContents(expectedProjects, cfg.projects)
|
||||||
|
|
25
src/test/nim/tserver.nim
Normal file
25
src/test/nim/tserver.nim
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import times, unittest
|
||||||
|
import ./testutil
|
||||||
|
import ../../main/nim/strawbosspkg/configuration
|
||||||
|
import ../../main/nim/strawbosspkg/server
|
||||||
|
|
||||||
|
let testuser = UserRef( # note: needs to correspond to an actual user
|
||||||
|
name: "bob@builder.com",
|
||||||
|
hashedPwd: "$2a$11$lVZ9U4optQMhzPh0E9A7Yu6XndXblUF3gCa.zmEvJy4F.4C4718b.")
|
||||||
|
|
||||||
|
let cfg = loadStrawBossConfig("src/test/json/strawboss.config.json")
|
||||||
|
|
||||||
|
## UNIT TESTS
|
||||||
|
suite "strawboss server":
|
||||||
|
test "can validate hashed pwd":
|
||||||
|
check validatePwd(testuser, "password")
|
||||||
|
|
||||||
|
test "can detect invalid pwds":
|
||||||
|
check (not validatePwd(testuser, "Password"))
|
||||||
|
|
||||||
|
test "can make and extract a JWT token from a session":
|
||||||
|
let session = newSession(testuser)
|
||||||
|
let tok = toJWT(cfg, session)
|
||||||
|
|
||||||
|
check:
|
||||||
|
fromJWT(cfg, tok) == session
|
@ -2,6 +2,7 @@
|
|||||||
"artifactsRepo": "artifacts",
|
"artifactsRepo": "artifacts",
|
||||||
"users": [],
|
"users": [],
|
||||||
"authSecret": "change me",
|
"authSecret": "change me",
|
||||||
|
"pwdCost": 11,
|
||||||
"projects": [
|
"projects": [
|
||||||
{ "name": "new-life-intro-band",
|
{ "name": "new-life-intro-band",
|
||||||
"repo": "/home/jdb/projects/new-life-introductory-band" },
|
"repo": "/home/jdb/projects/new-life-introductory-band" },
|
||||||
|
@ -12,3 +12,5 @@ srcDir = "src/main/nim"
|
|||||||
requires @["nim >= 0.16.1", "docopt >= 0.1.0", "tempfile", "jester", "bcrypt"]
|
requires @["nim >= 0.16.1", "docopt >= 0.1.0", "tempfile", "jester", "bcrypt"]
|
||||||
requires "https://github.com/yglukhov/nim-jwt"
|
requires "https://github.com/yglukhov/nim-jwt"
|
||||||
|
|
||||||
|
task test, "Runs the test suite.":
|
||||||
|
exec "nim c -r src/test/nim/runtests.nim"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user