diff --git a/src/main/json/service.config.schema.json b/src/main/json/service.config.schema.json index c321b68..f390d65 100644 --- a/src/main/json/service.config.schema.json +++ b/src/main/json/service.config.schema.json @@ -3,6 +3,8 @@ "type": "object", "properties": { "artifactsRepo": { "type": "string" }, + "authSecret": { "type": "string" }, + "pwdCost": { "type": "integer" }, "projects": { "title": "ProjectsList", "type": "array", @@ -30,19 +32,14 @@ "title": "UserDefinition", "type": "object", "properties": { - "username": { "type": "string" }, + "name": { "type": "string" }, "hashedPwd": { "type": "string" } }, - "required": ["username", "hashedPwd"], + "required": ["name", "hashedPwd"], "additionalProperties": false } - }, - "tokens": { - "title": "TokensList", - "type": "array", - "items": { "type": "string" } } }, - "required": ["artifactsRepo", "projects", "users", "tokens"], + "required": ["artifactsRepo", "authSecret", "pwdCost", "projects", "users"], "additionalProperties": false } diff --git a/src/main/nim/strawboss.nim b/src/main/nim/strawboss.nim index dc705f9..44908ee 100644 --- a/src/main/nim/strawboss.nim +++ b/src/main/nim/strawboss.nim @@ -25,6 +25,7 @@ when isMainModule: Usage: strawboss serve strawboss run [options] + strawboss hashpwd Options @@ -40,7 +41,6 @@ Options let args = docopt(doc, version = "strawboss v" & SB_VER) - echo $args if args["run"]: let req = RunRequest( @@ -62,3 +62,9 @@ Options elif args["serve"]: server.start(cfg) + elif args["hashpwd"]: + echo $cfg.pwdCost + let pwd = server.hashPwd($args[""], cfg.pwdCost) + echo pwd + echo pwd[0..28] + diff --git a/src/main/nim/strawbosspkg/configuration.nim b/src/main/nim/strawbosspkg/configuration.nim index b04b494..48f8e46 100644 --- a/src/main/nim/strawbosspkg/configuration.nim +++ b/src/main/nim/strawbosspkg/configuration.nim @@ -35,6 +35,7 @@ type artifactsRepo*: string authSecret*: string projects*: seq[ProjectDef] + pwdCost*: int8 users*: seq[UserRef] # Equality on custom types @@ -62,7 +63,7 @@ proc getIfExists(n: JsonNode, key: string): JsonNode = proc getOrFail(n: JsonNode, key: string, objName: string = ""): JsonNode = # 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] # Configuration parsing code @@ -97,6 +98,7 @@ proc loadStrawBossConfig*(cfgFile: string): StrawBossConfig = result = StrawBossConfig( artifactsRepo: jsonCfg.getIfExists("artifactsRepo").getStr("artifacts"), authSecret: jsonCfg.getOrFail("authSecret", "strawboss config").getStr, + pwdCost: int8(jsonCfg.getOrFail("pwdCost", "strawboss config").getNum), projects: projectDefs, users: users) @@ -170,8 +172,7 @@ proc `%`*(req: RunRequest): JsonNode = "stepName": req.stepName, "buildRef": req.buildRef, "workspaceDir": req.workspaceDir, - "forceRebuild": req.forceRebuild - } + "forceRebuild": req.forceRebuild } proc `$`*(s: BuildStatus): string = result = pretty(%s) proc `$`*(req: RunRequest): string = result = pretty(%req) diff --git a/src/main/nim/strawbosspkg/server.nim b/src/main/nim/strawbosspkg/server.nim index 70c9aa0..4be72fe 100644 --- a/src/main/nim/strawbosspkg/server.nim +++ b/src/main/nim/strawbosspkg/server.nim @@ -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 @@ -12,10 +13,9 @@ type Worker = object type Session = object user*: UserRef - issuedAt*, expires*: TimeInfo + issuedAt*, expires*: Time const ISO_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss" -const BCRYPT_ROUNDS = 16 proc makeJsonResp(status: HttpCode, details: string = ""): string = result = $(%* { @@ -24,28 +24,34 @@ proc makeJsonResp(status: HttpCode, details: string = ""): string = "details": details }) -proc newSession(user: UserRef): Session = +proc newSession*(user: UserRef): Session = result = Session( user: user, - issuedAt: getGMTime(getTime()), - expires: daysForward(7)) + issuedAt: getTime(), + expires: daysForward(7).toTime()) -proc toJWT(session: Session): JWT = - result = JWT( +proc toJWT*(cfg: StrawBossConfig, session: Session): string = +# 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"), claims: toClaims(%*{ "sub": session.user.name, - "iss": session.issuedAt.format(ISO_TIME_FORMAT), - "exp": session.expires.format(ISO_TIME_FORMAT) })) + "iat": session.issuedAt.toSeconds().int, + "exp": session.expires.toSeconds().int })) -proc extractSession(cfg: StrawBossConfig, request: Request): Session = + jwt.sign(cfg.authSecret) + result = $jwt - # Find the auth header - if not request.headers.hasKey("Authentication"): - raiseEx "No auth token." - - # Read and verify the JWT token - let jwt = toJWT(request.headers["Authentication"]) +proc fromJWT*(cfg: StrawBossConfig, strTok: string): Session = + let jwt = toJWT(strTok) var secret = cfg.authSecret if not jwt.verify(secret): raiseEx "Unable to verify auth token." jwt.verifyTimeClaims() @@ -57,13 +63,17 @@ proc extractSession(cfg: StrawBossConfig, request: Request): Session = result = Session( user: users[0], - issuedAt: parse(jwt.claims["iat"].node.str, ISO_TIME_FORMAT), - expires: parse(jwt.claims["exp"].node.str, ISO_TIME_FORMAT)) + issuedAt: fromSeconds(jwt.claims["iat"].node.num), + expires: fromSeconds(jwt.claims["exp"].node.num)) -template requireAuth() = - var session {.inject.}: Session - try: session = extractSession(givenCfg, request) - except: resp(Http401, makeJsonResp(Http401), "application/json") +proc extractSession(cfg: StrawBossConfig, request: Request): Session = + + # Find the auth header + 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 = let dir = mkdtemp() @@ -73,6 +83,32 @@ proc spawnWorker(req: RunRequest): Worker = process: startProcess("strawboss", ".", args, loadEnv(), {poUsePath}), 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 = var workers: seq[Worker] = @[] @@ -89,26 +125,9 @@ proc start*(givenCfg: StrawBossConfig): void = resp($(%(givenCfg.projects)), "application/json") get "/api/auth-token": - var username, pwd: string try: - username = @"username" - pwd = @"password" - 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)) + let authToken = makeAuthToken(givenCfg, @"username", @"password") + except: resp(Http401, makeJsonResp(Http401, getCurrentExceptionMsg())) post "/api/project/@projectName/@stepName/run/@buildRef?": workers.add(spawnWorker(RunRequest( diff --git a/src/test/json/strawboss.config.json b/src/test/json/strawboss.config.json index 7935b0b..0b9c044 100644 --- a/src/test/json/strawboss.config.json +++ b/src/test/json/strawboss.config.json @@ -1,10 +1,11 @@ { "artifactsRepo": "artifacts", + "authSecret": "change me", "users": [ { "name": "bob@builder.com", "hashedPwd": "testvalue" }, { "name": "sam@sousa.com", "hashedPwd": "testvalue" } ], - "authSecret": "change me", + "pwdCost": 11, "projects": [ { "name": "test-project-1", "repo": "/non-existent/dir", diff --git a/src/test/nim/runtests.nim b/src/test/nim/runtests.nim new file mode 100644 index 0000000..4c080dd --- /dev/null +++ b/src/test/nim/runtests.nim @@ -0,0 +1,4 @@ +import unittest + +import ./tserver.nim +import ./tconfiguration.nim diff --git a/src/test/nim/strawbosspkg/tconfiguration b/src/test/nim/strawbosspkg/tconfiguration deleted file mode 100644 index 2cc5094..0000000 Binary files a/src/test/nim/strawbosspkg/tconfiguration and /dev/null differ diff --git a/src/test/nim/strawbosspkg/tconfiguration.nim b/src/test/nim/tconfiguration.nim similarity index 97% rename from src/test/nim/strawbosspkg/tconfiguration.nim rename to src/test/nim/tconfiguration.nim index 225eaa7..a5b2f22 100644 --- a/src/test/nim/strawbosspkg/tconfiguration.nim +++ b/src/test/nim/tconfiguration.nim @@ -1,6 +1,6 @@ import strtabs, tables, unittest import ./testutil -import ../../../main/nim/strawbosspkg/configuration +import ../../main/nim/strawbosspkg/configuration suite "load and save configuration objects": @@ -23,6 +23,7 @@ suite "load and save configuration objects": check: cfg.artifactsRepo == "artifacts" cfg.authSecret == "change me" + cfg.pwdCost == 11 sameContents(expectedUsers, cfg.users) sameContents(expectedProjects, cfg.projects) diff --git a/src/test/nim/strawbosspkg/testutil.nim b/src/test/nim/testutil.nim similarity index 100% rename from src/test/nim/strawbosspkg/testutil.nim rename to src/test/nim/testutil.nim diff --git a/src/test/nim/tserver.nim b/src/test/nim/tserver.nim new file mode 100644 index 0000000..21b86ff --- /dev/null +++ b/src/test/nim/tserver.nim @@ -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 diff --git a/strawboss.config.json b/strawboss.config.json index 83745e5..c674130 100644 --- a/strawboss.config.json +++ b/strawboss.config.json @@ -2,6 +2,7 @@ "artifactsRepo": "artifacts", "users": [], "authSecret": "change me", + "pwdCost": 11, "projects": [ { "name": "new-life-intro-band", "repo": "/home/jdb/projects/new-life-introductory-band" }, diff --git a/strawboss.nimble b/strawboss.nimble index d47e6d8..c5f6f23 100644 --- a/strawboss.nimble +++ b/strawboss.nimble @@ -12,3 +12,5 @@ srcDir = "src/main/nim" requires @["nim >= 0.16.1", "docopt >= 0.1.0", "tempfile", "jester", "bcrypt"] requires "https://github.com/yglukhov/nim-jwt" +task test, "Runs the test suite.": + exec "nim c -r src/test/nim/runtests.nim"