From 1337d1710519cd17673ebec0c197f6cae7fd5cd9 Mon Sep 17 00:00:00 2001 From: Jonathan Bernard Date: Mon, 18 Feb 2019 17:53:08 -0600 Subject: [PATCH] WIP Refactor API into multiple sub-modules. --- api/personal_measure.config.json | 7 + api/personal_measure_api.nimble | 6 +- api/src/main/nim/personal_measure_api.nim | 245 +++++------------- .../main/nim/personal_measure_apipkg/api.nim | 146 +++++++++++ .../personal_measure_apipkg/configuration.nim | 27 ++ .../main/nim/personal_measure_apipkg/db.nim | 38 ++- .../nim/personal_measure_apipkg/db_common.nim | 11 + .../nim/personal_measure_apipkg/service.nim | 14 + 8 files changed, 305 insertions(+), 189 deletions(-) create mode 100644 api/personal_measure.config.json create mode 100644 api/src/main/nim/personal_measure_apipkg/api.nim create mode 100644 api/src/main/nim/personal_measure_apipkg/configuration.nim create mode 100644 api/src/main/nim/personal_measure_apipkg/service.nim diff --git a/api/personal_measure.config.json b/api/personal_measure.config.json new file mode 100644 index 0000000..4e35e34 --- /dev/null +++ b/api/personal_measure.config.json @@ -0,0 +1,7 @@ +{ + "authSecret":"change me", + "dbConnString":"host=localhost port=5500 dbname=personal_measure user=postgres password=password", + "debug":true, + "port":8080, + "pwdCost":11 +} diff --git a/api/personal_measure_api.nimble b/api/personal_measure_api.nimble index 978826e..ac9a149 100644 --- a/api/personal_measure_api.nimble +++ b/api/personal_measure_api.nimble @@ -8,9 +8,11 @@ description = "JDB\'s Personal Measures API" license = "MIT" srcDir = "src/main/nim" bin = @["personal_measure_api"] +skipExt = @["nim"] # Dependencies -requires @["nim >= 0.19.4", "bcrypt", "docopt >= 0.6.8", "isaac >= 0.1.3", "jester >= 0.4.1", "jwt", "tempfile", - "timeutils >= 0.4.0", "uuids >= 0.1.10" ] +requires @["nim >= 0.19.4", "bcrypt", "cliutils >= 0.6.3", "docopt >= 0.6.8", + "isaac >= 0.1.3", "jester >= 0.4.1", "jwt", "tempfile", "timeutils >= 0.4.0", + "uuids >= 0.1.10" ] diff --git a/api/src/main/nim/personal_measure_api.nim b/api/src/main/nim/personal_measure_api.nim index 9f4cf45..6b904cf 100644 --- a/api/src/main/nim/personal_measure_api.nim +++ b/api/src/main/nim/personal_measure_api.nim @@ -1,192 +1,83 @@ -import asyncdispatch, bcrypt, docopt, jester, json, jwt, options, times, timeutils +import cliutils, docopt, logging, jester, json, os, strutils, tables -import personal_measure_apipkg/models -import personal_measure_apipkg/db +import personal_measure_apipkg/configuration import personal_measure_apipkg/version +import personal_measure_apipkg/api -const JSON = "application/json" +const DEFAULT_CONFIG = PMApiConfig( + authSecret: "change me", + dbConnString: "", + debug: false, + port: 8080, + pwdCost: 11) -type - PersonalMeasureApiConfig = object - authSecret*: string - dbConnString*: string - debug*: bool - port*: int - pwdCost*: int +proc loadConfig*(args: Table[string, docopt.Value] = initTable[string, docopt.Value]()): PMApiConfig = - Session = object - user*: User - issuedAt*, expires*: Time + let filePath = + if args["--config"]: $args["--config"] + else: "personal_measure.config.json" -proc newSession*(user: User): Session = - result = Session( - user: user, - issuedAt: getTime().utc.trimNanoSec.toTime, - expires: daysForward(1).trimNanoSec.toTime) - -proc raiseEx*(reason: string): void = - raise newException(Exception, reason) - - -template halt(code: HttpCode, - headers: RawHeaders, - content: string): typed = - ## Immediately replies with the specified request. This means any further - ## code will not be executed after calling this template in the current - ## route. - bind TCActionSend, newHttpHeaders - result[0] = CallbackAction.TCActionSend - result[1] = code - result[2] = some(headers) - result[3] = content - result.matched = true - break allRoutes - -template jsonResp(code: HttpCode, details: string = "", headers: RawHeaders = @{:} ) = - halt( - code, - headers & @{"Content-Type": JSON}, - $(%* { - "statusCode": code.int, - "status": $code, - "details": details - }) - ) - -template json500Resp(ex: ref Exception, details: string = ""): void = - when not defined(release): debug ex.getStackTrace() - error details & ":\n" & ex.msg - jsonResp(Http500) - -proc toJWT*(cfg: PersonalMeasureApiConfig, session: Session): string = - ## Make a JST token for this session. - var jwt = JWT( - header: JOSEHeader(alg: HS256, typ: "jwt"), - claims: toClaims(%*{ - "sub": $(session.user.id), - "iat": session.issuedAt.toUnix.int, - "exp": session.expires.toUnix.int })) - - jwt.sign(cfg.authSecret) - result = $jwt - -proc fromJWT*(cfg: PersonalMeasureApiConfig, strTok: string): Session = - ## Validate a given JWT and extract the session data. - let jwt = toJWT(strTok) - var secret = cfg.authSecret - if not jwt.verify(secret): raiseEx "Unable to verify auth token." - jwt.verifyTimeClaims() - - # Find the user record (if authenticated) - let userId = jwt.claims["sub"].node.str - let user = db.getUser(userId) - if users.len != 1: raiseEx "Could not find session user." - - result = Session( - user: user, - issuedAt: fromUnix(jwt.claims["iat"].node.num), - expires: fromUnix(jwt.claims["exp"].node.num)) - -proc extractSession(cfg: PersonalMeasureApiConfig, request: Request): Session = - ## Helper to extract a session from a reqest. - - # Find the auth header - 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(cfg, headerVal[7..^1]) - -proc hashPwd*(pwd: string, cost: int8): string = - let salt = genSalt(cost) - result = hash(pwd, salt) - -proc validatePwd*(u: ref User, givenPwd: string): bool = - let salt = u.hashedPwd[0..28] # TODO: magic numbers - result = compare(u.hashedPwd, hash(givenPwd, salt)) - -proc makeAuthToken*(cfg: PersonalMeasureApiConfig, uname, pwd: string): string = - ## Given a username and pwd, validate the combination and generate a JWT - ## token string. - - if uname.len == 0 or pwd.len == 0: - 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" - - let session = newSession(user) - - result = toJWT(cfg, session) - -proc makeApiKey*(cfg: PersonalMeasureApiConfig, uname: string): string = - ## Given a username, make an API token (JWT token string that does not - ## expire). Note that this does not validate the username/pwd combination. It - ## is not intended to be exposed publicly via the API, but serve as a utility - ## function for an administrator to setup a unsupervised account (git access - ## for example). - - if uname.len == 0: raiseEx "no username given" - - # find the user record - let users = cfg.users.filterIt(it.name == uname) - if users.len != 1: raiseEx "invalid username" - - let session = Session( - user: users[0], - issuedAt: getTime(), - expires: daysForward(365 * 1000).toTime()) - - result = toJWT(cfg, session); - -template checkAuth() = - ## Check this request for authentication and authorization information. - ## Injects the session into the running context. If the request is not - ## authorized, this template returns an appropriate 401 response. - - var session {.inject.}: Session - - try: session = extractSession(cfg, request) + var json: JsonNode + try: json = parseFile(filePath) except: - debug "Auth failed: " & getCurrentExceptionMsg() - jsonResp(Http401, "Unauthorized", @{"WWW-Authenticate": "Bearer"}) + json = %DEFAULT_CONFIG + if not existsFile(filePath): + info "created new configuration file \"" & filePath & "\"" + filePath.writeFile($json) + else: + warn "Cannot read configuration file \"" & filePath & "\":\n\t" & + getCurrentExceptionMsg() + let cfg = CombinedConfig(docopt: args, json: json) -proc start*(cfg: PersonalMeasureApiConfig): void = + result = PMApiConfig( + authSecret: cfg.getVal("authSecret"), + dbConnString: cfg.getVal("dbConnString"), + debug: "true".startsWith(cfg.getVal("debug", "false").toLower()), + port: parseInt(cfg.getVal("port", "8080")), + pwdCost: parseInt(cfg.getVal("pwdCost", "11"))) + +proc initContext(args: Table[string, docopt.Value]): PMApiContext = - var stopFuture = newFuture[void]() + var cfg: PMApiConfig + var db: PMApiDb - settings: - port = Port(cfg.port) - appName = "/api" + try: cfg = loadConfig(args) + except: raiseEx "Unable to load configuration: \n\t" & getCurrentExceptionMsg() - routes: - - get "/version": - resp($(%("personal_measure_api v" & PM_API_VERSION)), JSON) - - post "/service/debug/stop": - if not cfg.debug: jsonResp(Http404) - else: - let shutdownFut = sleepAsync(100) - shutdownFut.callback = proc(): void = complete(stopFuture) - resp($(%"shutting down"), JSON) - - waitFor(stopFuture) + try: db = connect(cfg.dbConnString) + except: raiseEx "Unable to connect to the database:\n\t" & getCurrentExceptionMsg() + result = PMApiContext(cfg: cfg, db: db) when isMainModule: - start(PersonalMeasureApiConfig( - debug: true, - port: 8090, - pwdCost: 11, - dbConnString: "host=localhost port=5500 username=postgres password=password dbname=personal_measure")) + + try: + let doc = """ +Usage: + personal_measure_api test [options] + personal_measure_api serve [options] + +Options: + + -C, --config Location of the config file (defaults to personal_measure.config.json) +""" + logging.addHandler(newConsoleLogger()) + + # Initialize our service context + let args = docopt(doc, version = PM_API_VERSION) + let ctx = initContext(args) + + if args["test"]: + echo "Test" + + if args["serve"]: + start(PMApiConfig( + debug: true, + port: 8090, + pwdCost: 11, + dbConnString: "host=localhost port=5500 username=postgres password=password dbname=personal_measure")) + except: + fatal "pit: " & getCurrentExceptionMsg() + #raise getCurrentException() + quit(QuitFailure) diff --git a/api/src/main/nim/personal_measure_apipkg/api.nim b/api/src/main/nim/personal_measure_apipkg/api.nim new file mode 100644 index 0000000..e794adb --- /dev/null +++ b/api/src/main/nim/personal_measure_apipkg/api.nim @@ -0,0 +1,146 @@ +import asyncdispatch, jester, json, jwt, strutils, times, timeutils, uuids + +import ./db +import ./configuration +import ./models +import ./service + +const JSON = "application/json" + +type + Session* = object + user*: User + issuedAt*, expires*: Time + +proc newSession*(user: User): Session = + result = Session( + user: user, + issuedAt: getTime().utc.trimNanoSec.toTime, + expires: daysForward(1).trimNanoSec.toTime) + +template halt(code: HttpCode, + headers: RawHeaders, + content: string): typed = + ## Immediately replies with the specified request. This means any further + ## code will not be executed after calling this template in the current + ## route. + bind TCActionSend, newHttpHeaders + result[0] = CallbackAction.TCActionSend + result[1] = code + result[2] = some(headers) + result[3] = content + result.matched = true + break allRoutes + +template jsonResp(code: HttpCode, details: string = "", headers: RawHeaders = @{:} ) = + halt( + code, + headers & @{"Content-Type": JSON}, + $(%* { + "statusCode": code.int, + "status": $code, + "details": details + }) + ) + +template json500Resp(ex: ref Exception, details: string = ""): void = + when not defined(release): debug ex.getStackTrace() + error details & ":\n" & ex.msg + jsonResp(Http500) + +proc toJWT*(ctx: PMApiContext, session: Session): string = + ## Make a JST token for this session. + var jwt = JWT( + header: JOSEHeader(alg: HS256, typ: "jwt"), + claims: toClaims(%*{ + "sub": $(session.user.id), + "iat": session.issuedAt.toUnix.int, + "exp": session.expires.toUnix.int })) + + jwt.sign(ctx.cfg.authSecret) + result = $jwt + +proc fromJWT*(ctx: PMApiContext, strTok: string): Session = + ## Validate a given JWT and extract the session data. + let jwt = toJWT(strTok) + var secret = ctx.cfg.authSecret + if not jwt.verify(secret): raiseEx "Unable to verify auth token." + jwt.verifyTimeClaims() + + # Find the user record (if authenticated) + let userId = jwt.claims["sub"].node.str + var user: User + try: user = ctx.db.getUser(parseUUID(userId)) + except: raiseEx "unknown user" + + result = Session( + user: user, + issuedAt: fromUnix(jwt.claims["iat"].node.num), + expires: fromUnix(jwt.claims["exp"].node.num)) + +proc extractSession(ctx: PMApiContext, request: Request): Session = + ## Helper to extract a session from a reqest. + + # Find the auth header + 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]) + +proc makeAuthToken*(ctx: PMApiContext, email, pwd: string): string = + ## Given a user's email and pwd, validate the combination and generate a JWT + ## token string. + + if email.len == 0 or pwd.len == 0: + raiseEx "fields 'username' and 'password' required" + + # find the user record + var user: User + try: user = ctx.db.getUserByEmail(email) + except: raiseEx "invalid username or password" + + if not validatePwd(user, pwd): raiseEx "invalid username or password" + + let session = newSession(user) + + result = toJWT(ctx, session) + +template checkAuth() = + ## Check this request for authentication and authorization information. + ## Injects the session into the running context. If the request is not + ## authorized, this template returns an appropriate 401 response. + + var session {.inject.}: Session + + try: session = extractSession(cfg, request) + except: + debug "Auth failed: " & getCurrentExceptionMsg() + jsonResp(Http401, "Unauthorized", @{"WWW-Authenticate": "Bearer"}) + +proc start*(cfg: PMApiConfig): void = + + var stopFuture = newFuture[void]() + + settings: + port = Port(cfg.port) + appName = "/api" + + routes: + + get "/version": + resp($(%("personal_measure_api v" & PM_API_VERSION)), JSON) + + post "/service/debug/stop": + if not 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 new file mode 100644 index 0000000..dc8d9a0 --- /dev/null +++ b/api/src/main/nim/personal_measure_apipkg/configuration.nim @@ -0,0 +1,27 @@ +import json + +import ./db + +type + PMApiConfig* = object + authSecret*: string + dbConnString*: string + debug*: bool + port*: int + pwdCost*: int + + PMApiContext* = object + cfg*: PMApiConfig + db*: PMApiDb + +proc `%`*(cfg: PMApiConfig): JsonNode = + result = %* { + "authSecret": cfg.authSecret, + "dbConnString": cfg.dbConnString, + "debug": cfg.debug, + "port": cfg.port, + "pwdCost": cfg.pwdCost } + +proc raiseEx*(reason: string): void = + raise newException(Exception, reason) + diff --git a/api/src/main/nim/personal_measure_apipkg/db.nim b/api/src/main/nim/personal_measure_apipkg/db.nim index ed4ebd7..4f18d47 100644 --- a/api/src/main/nim/personal_measure_apipkg/db.nim +++ b/api/src/main/nim/personal_measure_apipkg/db.nim @@ -4,14 +4,32 @@ import db_postgres, macros, options, postgres, sequtils, strutils, times, import ./models import ./db_common -# proc create: Typed create methods for specific records -proc createUser*(db: DbConn, user: User): User = return db.createRecord(user) -proc createApiToken*(db: DbConn, token: ApiToken): ApiToken = return db.createRecord(token) -proc createMeasure*(db: DbConn, measure: Measure): Measure = return db.createRecord(measure) -proc createValue*(db: DbConn, value: Value): Value = return db.createRecord(value) +type + PMApiDb* = ref object + conn: DbConn -proc getUser*(db: DbConn, id: UUID): User = return db.getRecord(User, id) -proc getUser*(db: DbConn, id: string): User = return db.getRecord(User, parseUUID(id)) -proc getApiToken*(db: DbConn, id: UUID): ApiToken = return db.getRecord(ApiToken, id) -proc getMeasure*(db: DbConn, id: UUID): Measure = return db.getRecord(Measure, id) -proc getValue*(db: DbConn, id: UUID): Value = return db.getRecord(Value, id) + +proc connect*(connString: string): PMApiDb = + result = PMApiDb(conn: open("", "", "", connString)) + +macro makeGetRecord(modelType: type): untyped = + echo modelType.getType.treeRepr +#[ + let procIdent = ident("get" & $modelType.getType[1]) + return quote do: + proc `procIdent`*(db: PMApiDb, id: UUID): `modelType` = return db.conn.getRecord(`modelType`, id) +]# + +proc createUser*(db: PMApiDb, user: User): User = return db.conn.createRecord(user) +proc createApiToken*(db: PMApiDb, token: ApiToken): ApiToken = return db.conn.createRecord(token) +proc createMeasure*(db: PMApiDb, measure: Measure): Measure = return db.conn.createRecord(measure) +proc createValue*(db: PMApiDb, value: Value): Value = return db.conn.createRecord(value) + +proc getUser*(db: PMApiDb, id: UUID): User = return db.conn.getRecord(User, id) +proc getUser*(db: PMApiDb, id: string): User = return db.conn.getRecord(User, parseUUID(id)) +proc getApiToken*(db: PMApiDb, id: UUID): ApiToken = return db.conn.getRecord(ApiToken, id) +proc getMeasure*(db: PMApiDb, id: UUID): Measure = return db.conn.getRecord(Measure, id) +proc getValue*(db: PMApiDb, id: UUID): Value = return db.conn.getRecord(Value, id) + +#proc getUsersWhere*(db: PMApiDb, whereClause: string, values: varargs[string, dbFormat]): User = +# return db.conn.getRecordsWhere(User, whereClause, values) 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 8ab14fd..fd9f61f 100644 --- a/api/src/main/nim/personal_measure_apipkg/db_common.nim +++ b/api/src/main/nim/personal_measure_apipkg/db_common.nim @@ -40,8 +40,19 @@ template getRecord*(db: DbConn, modelType: type, id: UUID): untyped = "SELECT " & columnNamesForModel(modelType).join(",") & " FROM " & tableName(modelType) & " WHERE id = ?"), @[$id]) + + if row.allIt(it.len == 0): + raise newException(KeyError, "no record for id " & $id) + rowToModel(modelType, row) +template getRecordsWhere*(db: DbConn, modelType: type, whereClause: string, values: varargs[string, dbFormat]): untyped = + db.getAllRows(sql( + "SELECT " & columnNamesForModel(modelType).join(",") & + " FROM " & tableName(modelType) & + " WHERE " & whereClause), values) + .mapIt(rowToModel(modelType, it)) + template getAllRecords*(db: DbConn, modelType: type): untyped = db.getAllRows(sql( "SELECT " & columnNamesForModel(modelType).join(",") & diff --git a/api/src/main/nim/personal_measure_apipkg/service.nim b/api/src/main/nim/personal_measure_apipkg/service.nim new file mode 100644 index 0000000..80843e9 --- /dev/null +++ b/api/src/main/nim/personal_measure_apipkg/service.nim @@ -0,0 +1,14 @@ +import bcrypt + +import ./configuration +import ./db +import ./models + +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 + result = compare(u.hashedPwd, hash(givenPwd, salt)) +