From c98497526e04a1fb59d6ad107117d0cdbb30e8a1 Mon Sep 17 00:00:00 2001 From: Jonathan Bernard Date: Tue, 19 Feb 2019 13:26:35 -0600 Subject: [PATCH] WIP Implementing API tokens. --- api/api.txt | 6 +- api/src/main/nim/personal_measure_api.nim | 19 ++-- .../main/nim/personal_measure_apipkg/api.nim | 97 +++++++++++++++---- .../personal_measure_apipkg/configuration.nim | 2 +- .../main/nim/personal_measure_apipkg/db.nim | 5 +- .../nim/personal_measure_apipkg/db_common.nim | 13 ++- .../nim/personal_measure_apipkg/db_util.nim | 2 +- .../nim/personal_measure_apipkg/models.nim | 9 +- .../nim/personal_measure_apipkg/service.nim | 7 +- .../20190214122514-initial-schema-up.sql | 3 +- 10 files changed, 119 insertions(+), 44 deletions(-) diff --git a/api/api.txt b/api/api.txt index 395404c..81ac90c 100644 --- a/api/api.txt +++ b/api/api.txt @@ -20,9 +20,9 @@ Personal Measure API ☑ GET /auth-token Given a valid username/password combo, get an auth token. ☑ GET /user Given a valid auth token, return the user details. -☐ GET /api-tokens List api tokens. -☐ DELETE /api-tokens/ Delete a specific api token. -☐ POST /api-tokens With a valid session, create a new api token. +☑ GET /api-tokens List api tokens. +☑ DELETE /api-tokens/ Delete a specific api token. +☑ POST /api-tokens With a valid session, create a new api token. Legend ------ diff --git a/api/src/main/nim/personal_measure_api.nim b/api/src/main/nim/personal_measure_api.nim index ba61b68..8aa9bb1 100644 --- a/api/src/main/nim/personal_measure_api.nim +++ b/api/src/main/nim/personal_measure_api.nim @@ -39,7 +39,7 @@ proc loadConfig*(args: Table[string, docopt.Value] = initTable[string, docopt.Va dbConnString: cfg.getVal("dbConnString"), debug: "true".startsWith(cfg.getVal("debug", "false").toLower()), port: parseInt(cfg.getVal("port", "8080")), - pwdCost: parseInt(cfg.getVal("pwdCost", "11"))) + pwdCost: cast[int8](parseInt(cfg.getVal("pwdCost", "11")))) proc initContext(args: Table[string, docopt.Value]): PMApiContext = @@ -61,28 +61,31 @@ when isMainModule: Usage: personal_measure_api test [options] personal_measure_api serve [options] - personal_measure_api hashpwd [] [options] + personal_measure_api hashpwd [options] Options: -C, --config Location of the config file (defaults to personal_measure.config.json) + --salt Use a given salt for password hashing + --pwdCost Specify the cost for password hashing """ logging.addHandler(newConsoleLogger()) # Initialize our service context let args = docopt(doc, version = PM_API_VERSION) + echo $args let ctx = initContext(args) if args["hashpwd"]: - let cost = - if args[""]: parseInt($args[""]) - else: 11 - - echo hashPwd($args[""], cast[int8](cost)) + if args["--salt"]: + echo hashPwd($args[""], $args["--salt"]) + else: + let cost = ctx.cfg.pwdCost + echo hashPwd($args[""], cast[int8](cost)) if args["test"]: echo "test" - echo ctx.db.getUserByEmail("jonathan@jdbernard.com") + echo ctx.db.getUsersByEmail("jonathan@jdbernard.com") if args["serve"]: start(ctx) diff --git a/api/src/main/nim/personal_measure_apipkg/api.nim b/api/src/main/nim/personal_measure_apipkg/api.nim index 0df45c2..62a0b64 100644 --- a/api/src/main/nim/personal_measure_apipkg/api.nim +++ b/api/src/main/nim/personal_measure_apipkg/api.nim @@ -1,5 +1,7 @@ -import asyncdispatch, jester, json, jwt, logging, options, sequtils, strutils, - times, timeutils, uuids +import asyncdispatch, base64, jester, json, jwt, logging, options, sequtils, + strutils, times, uuids +import timeutils except `<` + import ./db, ./configuration, ./models, ./service, ./version const JSON = "application/json" @@ -75,6 +77,44 @@ proc fromJWT*(ctx: PMApiContext, strTok: string): Session = issuedAt: fromUnix(jwt.claims["iat"].node.num), expires: fromUnix(jwt.claims["exp"].node.num)) +proc fromApiToken*(ctx: PMApiContext, strTok: string): Session = + info "fromApiToken" + let pairs = strTok.decode.split(":") + let email = pairs[0] + let apiTokVal = pairs[1] + + info "email: " & email & "\tapiTokVal: " & apiTokVal + # Look up the user + var user: User + try: + let users = ctx.db.getUsersByEmail(email) + if users.len != 1: raiseEx "" + user = users[0] + info "Found user: " & $user + except: raiseEx "invalid username or password" + + # look for the ApiToken record, hashing the token provided with the user's salt + let hashedToken = hashPwd(apiTokVal, user.salt) + echo "Hashed token: " & hashedToken + let foundTokens = ctx.db.getApiTokensByHashedToken(hashedToken) + if foundTokens.len != 1: raiseEx "invalid username or password" + + let apiToken = foundTokens[0] + + # make sure that if we found the token it is for this user (don't expect + # another user's salt to be able to hash this user's pwd correctly, but eh) + if apiToken.userId != user.id: raiseEx "invalid username or password" + + # make sure the token has not expired + if apiToken.expires.isSome and apiToken.expires.get < getTime().utc: + raiseEx "invalid username or password" + + # finally, if we're here, we know that this is a valid api token + result = Session( + user: user, + issuedAt: getTime().utc.trimNanoSec.toTime, + expires: (getTime().utc.trimNanoSec + 1.minutes).toTime) + proc extractSession(ctx: PMApiContext, request: Request): Session = ## Helper to extract a session from a reqest. @@ -82,12 +122,15 @@ proc extractSession(ctx: PMApiContext, request: Request): Session = if not request.headers.hasKey("Authorization"): raiseEx "No auth token." - # Read and verify the JWT token let headerVal = request.headers["Authorization"] - if not headerVal.startsWith("Bearer "): - raiseEx "Invalid Authentication type (only 'Bearer' is supported)." - result = fromJWT(ctx, headerVal[7..^1]) + # If they gave us an API Token, validate that to get the session + if headerVal.startsWith("Basic "): + result = fromApiToken(ctx, headerVal[6..^1]) + elif headerVal.startsWith("Bearer "): + result = fromJWT(ctx, headerVal[7..^1]) + else: + raiseEx "Invalid Authentication type ('Bearer' and 'Basic' supported)." proc makeAuthToken*(ctx: PMApiContext, email, pwd: string): string = ## Given a user's email and pwd, validate the combination and generate a JWT @@ -99,7 +142,7 @@ proc makeAuthToken*(ctx: PMApiContext, email, pwd: string): string = # find the user record var user: User try: - let users = ctx.db.getUserByEmail(email) + let users = ctx.db.getUsersByEmail(email) if users.len != 1: raiseEx "" user = users[0] except: raiseEx "invalid username or password" @@ -155,17 +198,10 @@ proc start*(ctx: PMApiContext): void = resp(Http200, $(%session.user), JSON) - post "/service/debug/stop": - if not ctx.cfg.debug: jsonResp(Http404) - else: - let shutdownFut = sleepAsync(100) - shutdownFut.callback = proc(): void = complete(stopFuture) - resp($(%"shutting down"), JSON) - get "/api-tokens": checkAuth() - resp(Http200, $(%ctx.db.getApiTokenByUserId($session.user.id))) + resp(Http200, $(%ctx.db.getApiTokensByUserId($session.user.id))) post "/api-tokens": checkAuth() @@ -174,7 +210,6 @@ proc start*(ctx: PMApiContext): void = try: let jsonBody = parseJson(request.body) newToken = ApiToken( - id: genUUID(), userId: session.user.id, name: jsonBody["name"].getStr, expires: none[DateTime](), @@ -182,12 +217,32 @@ proc start*(ctx: PMApiContext): void = except: jsonResp(Http400) try: - let tokenValue = "" # TODO - newToken.hashedToken = hashPwd(tokenValue) - ctx.db.createApiToken(token) + let tokenValue = randomString(ctx.cfg.pwdCost) + newToken.hashedToken = hashPwd(tokenValue, session.user.salt) + newToken = ctx.db.createApiToken(newToken) let respToken = %newToken - newToken["value"] = tokenValue - resp($newToken, JSON) + respToken["value"] = %tokenValue + resp($respToken, JSON) + except: + debug getCurrentExceptionMsg() + jsonResp(Http500) + + delete "/api-tokens/@tokenId": + info "Request to delete API Token" + checkAuth() + + try: + let token = ctx.db.getApiToken(parseUUID(@"tokenId")) + if ctx.db.deleteApiToken(token): jsonResp(Http200) + else: jsonResp(Http500) + except: jsonResp(Http404) + + post "/service/debug/stop": + if not ctx.cfg.debug: jsonResp(Http404) + else: + let shutdownFut = sleepAsync(100) + shutdownFut.callback = proc(): void = complete(stopFuture) + resp($(%"shutting down"), JSON) waitFor(stopFuture) diff --git a/api/src/main/nim/personal_measure_apipkg/configuration.nim b/api/src/main/nim/personal_measure_apipkg/configuration.nim index dc8d9a0..8e4f6f7 100644 --- a/api/src/main/nim/personal_measure_apipkg/configuration.nim +++ b/api/src/main/nim/personal_measure_apipkg/configuration.nim @@ -8,7 +8,7 @@ type dbConnString*: string debug*: bool port*: int - pwdCost*: int + pwdCost*: int8 PMApiContext* = object cfg*: PMApiConfig diff --git a/api/src/main/nim/personal_measure_apipkg/db.nim b/api/src/main/nim/personal_measure_apipkg/db.nim index dd1dad7..e414a0c 100644 --- a/api/src/main/nim/personal_measure_apipkg/db.nim +++ b/api/src/main/nim/personal_measure_apipkg/db.nim @@ -12,9 +12,10 @@ type proc connect*(connString: string): PMApiDb = result = PMApiDb(conn: open("", "", "", connString)) -generateProcsForModels([User, ApiToken, Measure, Value]) +generateProcsForModels([User, ApiToken, Measure, Measurement]) generateLookup(User, @["email"]) generateLookup(ApiToken, @["userId"]) +generateLookup(ApiToken, @["hashedToken"]) generateLookup(Measure, @["userId"]) -generateLookup(Value, @["measureId"]) +generateLookup(Measurement, @["measureId"]) diff --git a/api/src/main/nim/personal_measure_apipkg/db_common.nim b/api/src/main/nim/personal_measure_apipkg/db_common.nim index 2b3666c..4ffa835 100644 --- a/api/src/main/nim/personal_measure_apipkg/db_common.nim +++ b/api/src/main/nim/personal_measure_apipkg/db_common.nim @@ -37,6 +37,12 @@ proc updateRecord*[T](db: DbConn, rec: T): bool = return numRowsUpdated > 0; +template deleteRecord*(db: DbConn, modelType: type, id: UUID): untyped = + db.tryExec(sql("DELETE FROM " & tableName(modelType) & " WHERE id = ?"), $id) + +proc deleteRecord*[T](db: DbConn, rec: T): bool = + return db.tryExec(sql("DELETE FROM " & tableName(rec) & " WHERE id = ?"), $rec.id) + template getRecord*(db: DbConn, modelType: type, id: UUID): untyped = let row = db.getRow(sql( "SELECT " & columnNamesForModel(modelType).join(",") & @@ -79,6 +85,7 @@ macro generateProcsForModels*(modelTypes: openarray[type]): untyped = let getWhereName = ident("get" & modelName & "sWhere") let createName = ident("create" & modelName) let updateName = ident("update" & modelName) + let deleteName = ident("delete" & modelName) result.add quote do: proc `getName`*(db: PMApiDb, id: UUID): `t` = getRecord(db.conn, `t`, id) proc `getAllName`*(db: PMApiDb): seq[`t`] = getAllRecords(db.conn, `t`) @@ -86,10 +93,12 @@ macro generateProcsForModels*(modelTypes: openarray[type]): untyped = return getRecordsWhere(db.conn, `t`, whereClause, values) proc `createName`*(db: PMApiDb, rec: `t`): `t` = createRecord(db.conn, rec) proc `updateName`*(db: PMApiDb, rec: `t`): bool = updateRecord(db.conn, rec) + proc `deleteName`*(db: PMApiDb, rec: `t`): bool = deleteRecord(db.conn, rec) + proc `deleteName`*(db: PMApiDb, id: UUID): bool = deleteRecord(db.conn, `t`, id) macro generateLookup*(modelType: type, fields: seq[string]): untyped = let fieldNames = fields[1].mapIt($it) - let procName = ident("get" & $modelType.getType[1] & "By" & fieldNames.mapIt(it.capitalize).join("And")) + let procName = ident("get" & $modelType.getType[1] & "sBy" & fieldNames.mapIt(it.capitalize).join("And")) # Create proc skeleton result = quote do: @@ -101,7 +110,7 @@ macro generateLookup*(modelType: type, fields: seq[string]): untyped = # Add dynamic parameters for the proc definition and inner proc call for n in fieldNames: let paramTuple = newNimNode(nnkPar) - paramTuple.add(newColonExpr(ident("field"), newLit(n))) + paramTuple.add(newColonExpr(ident("field"), newLit(identNameToDb(n)))) paramTuple.add(newColonExpr(ident("value"), ident(n))) result[3].add(newIdentDefs(ident(n), ident("string"))) diff --git a/api/src/main/nim/personal_measure_apipkg/db_util.nim b/api/src/main/nim/personal_measure_apipkg/db_util.nim index ae9d757..1c415da 100644 --- a/api/src/main/nim/personal_measure_apipkg/db_util.nim +++ b/api/src/main/nim/personal_measure_apipkg/db_util.nim @@ -215,7 +215,7 @@ macro populateMutateClauses*(t: typed, newRecord: bool, mc: var MutateClauses): if `newRecord` and not `t`.id.isZero: raise newException( AssertionError, - "Trying to create a new record, but the record already has an ID.") + "Trying to create a new record, but the record already has an ID (" & $(`t`.id) & ").") # if we're looking at an optional field, add logic to check for presence elif fieldType.kind == nnkBracketExpr and diff --git a/api/src/main/nim/personal_measure_apipkg/models.nim b/api/src/main/nim/personal_measure_apipkg/models.nim index f4b3677..e8bc36e 100644 --- a/api/src/main/nim/personal_measure_apipkg/models.nim +++ b/api/src/main/nim/personal_measure_apipkg/models.nim @@ -6,6 +6,7 @@ type displayName*: string email*: string hashedPwd*: string + salt*: string ApiToken* = object id*: UUID @@ -26,7 +27,7 @@ type rangeUnits*: string analysis*: seq[string] - Value* = object + Measurement* = object id*: UUID measureId*: UUID value*: int @@ -44,8 +45,8 @@ proc `$`*(tok: ApiToken): string = proc `$`*(m: Measure): string = return "Measure " & ($m.id)[0..6] & " - " & m.slug -proc `$`*(v: Value): string = - return "Value " & ($v.id)[0..6] & " - " & ($v.measureId)[0..6] & " = " & $v.value +proc `$`*(v: Measurement): string = + return "Measurement " & ($v.id)[0..6] & " - " & ($v.measureId)[0..6] & " = " & $v.value proc `%`*(u: User): JsonNode = result = %*{ @@ -79,7 +80,7 @@ proc `%`*(m: Measure): JsonNode = if m.domainSource.isSome: result["domainSource"] = %(m.domainSource.get) if m.rangeSource.isSome: result["rangeSource"] = %(m.rangeSource.get) -proc `%`*(v: Value): JsonNode = +proc `%`*(v: Measurement): JsonNode = result = %*{ "id": $v.id, "measureId": $v.measureId, diff --git a/api/src/main/nim/personal_measure_apipkg/service.nim b/api/src/main/nim/personal_measure_apipkg/service.nim index 80843e9..e815818 100644 --- a/api/src/main/nim/personal_measure_apipkg/service.nim +++ b/api/src/main/nim/personal_measure_apipkg/service.nim @@ -4,11 +4,16 @@ import ./configuration import ./db import ./models +proc randomString*(cost: int8): string = genSalt(cost) + +proc hashPwd*(pwd: string, salt: string): string = + result = hash(pwd, salt) + proc hashPwd*(pwd: string, cost: int8): string = let salt = genSalt(cost) result = hash(pwd, salt) proc validatePwd*(u: User, givenPwd: string): bool = - let salt = u.hashedPwd[0..28] # TODO: magic numbers + let salt = u.salt result = compare(u.hashedPwd, hash(givenPwd, salt)) diff --git a/api/src/main/sql/migrations/20190214122514-initial-schema-up.sql b/api/src/main/sql/migrations/20190214122514-initial-schema-up.sql index 28b329c..a90f8be 100644 --- a/api/src/main/sql/migrations/20190214122514-initial-schema-up.sql +++ b/api/src/main/sql/migrations/20190214122514-initial-schema-up.sql @@ -5,7 +5,8 @@ create table "users" ( id uuid default uuid_generate_v4() primary key, display_name varchar not null, email varchar not null unique, - hashed_pwd varchar not null + hashed_pwd varchar not null, + salt varchar not null ); create table "api_tokens" (