WIP Refactor API into multiple sub-modules.

This commit is contained in:
Jonathan Bernard 2019-02-18 17:53:08 -06:00
parent 7bba4a0ad7
commit 1337d17105
8 changed files with 305 additions and 189 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(",") &

View File

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