Implemented password hashing. Added and improved tests.

This commit is contained in:
Jonathan Bernard 2017-03-24 01:04:39 -05:00
parent b5a70f6de0
commit 52b7d2f48b
12 changed files with 113 additions and 56 deletions

View File

@ -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
} }

View File

@ -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]

View File

@ -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)

View File

@ -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(

View File

@ -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",

View File

@ -0,0 +1,4 @@
import unittest
import ./tserver.nim
import ./tconfiguration.nim

View File

@ -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
View 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

View File

@ -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" },

View File

@ -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"