WIP Implementing API tokens.
This commit is contained in:
parent
706713f57a
commit
c98497526e
@ -20,9 +20,9 @@ Personal Measure API
|
|||||||
|
|
||||||
☑ GET /auth-token Given a valid username/password combo, get an auth token.
|
☑ 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 /user Given a valid auth token, return the user details.
|
||||||
☐ GET /api-tokens List api tokens.
|
☑ GET /api-tokens List api tokens.
|
||||||
☐ DELETE /api-tokens/<id> Delete a specific api token.
|
☑ DELETE /api-tokens/<id> Delete a specific api token.
|
||||||
☐ POST /api-tokens With a valid session, create a new api token.
|
☑ POST /api-tokens With a valid session, create a new api token.
|
||||||
|
|
||||||
Legend
|
Legend
|
||||||
------
|
------
|
||||||
|
@ -39,7 +39,7 @@ proc loadConfig*(args: Table[string, docopt.Value] = initTable[string, docopt.Va
|
|||||||
dbConnString: cfg.getVal("dbConnString"),
|
dbConnString: cfg.getVal("dbConnString"),
|
||||||
debug: "true".startsWith(cfg.getVal("debug", "false").toLower()),
|
debug: "true".startsWith(cfg.getVal("debug", "false").toLower()),
|
||||||
port: parseInt(cfg.getVal("port", "8080")),
|
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 =
|
proc initContext(args: Table[string, docopt.Value]): PMApiContext =
|
||||||
|
|
||||||
@ -61,28 +61,31 @@ when isMainModule:
|
|||||||
Usage:
|
Usage:
|
||||||
personal_measure_api test [options]
|
personal_measure_api test [options]
|
||||||
personal_measure_api serve [options]
|
personal_measure_api serve [options]
|
||||||
personal_measure_api hashpwd <password> [<cost>] [options]
|
personal_measure_api hashpwd <password> [options]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
|
|
||||||
-C, --config <cfgFile> Location of the config file (defaults to personal_measure.config.json)
|
-C, --config <cfgFile> Location of the config file (defaults to personal_measure.config.json)
|
||||||
|
--salt <salt> Use a given salt for password hashing
|
||||||
|
--pwdCost <cost> Specify the cost for password hashing
|
||||||
"""
|
"""
|
||||||
logging.addHandler(newConsoleLogger())
|
logging.addHandler(newConsoleLogger())
|
||||||
|
|
||||||
# Initialize our service context
|
# Initialize our service context
|
||||||
let args = docopt(doc, version = PM_API_VERSION)
|
let args = docopt(doc, version = PM_API_VERSION)
|
||||||
|
echo $args
|
||||||
let ctx = initContext(args)
|
let ctx = initContext(args)
|
||||||
|
|
||||||
if args["hashpwd"]:
|
if args["hashpwd"]:
|
||||||
let cost =
|
if args["--salt"]:
|
||||||
if args["<cost>"]: parseInt($args["<cost>"])
|
echo hashPwd($args["<password>"], $args["--salt"])
|
||||||
else: 11
|
else:
|
||||||
|
let cost = ctx.cfg.pwdCost
|
||||||
echo hashPwd($args["<password>"], cast[int8](cost))
|
echo hashPwd($args["<password>"], cast[int8](cost))
|
||||||
|
|
||||||
if args["test"]:
|
if args["test"]:
|
||||||
echo "test"
|
echo "test"
|
||||||
echo ctx.db.getUserByEmail("jonathan@jdbernard.com")
|
echo ctx.db.getUsersByEmail("jonathan@jdbernard.com")
|
||||||
|
|
||||||
if args["serve"]: start(ctx)
|
if args["serve"]: start(ctx)
|
||||||
|
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import asyncdispatch, jester, json, jwt, logging, options, sequtils, strutils,
|
import asyncdispatch, base64, jester, json, jwt, logging, options, sequtils,
|
||||||
times, timeutils, uuids
|
strutils, times, uuids
|
||||||
|
import timeutils except `<`
|
||||||
|
|
||||||
import ./db, ./configuration, ./models, ./service, ./version
|
import ./db, ./configuration, ./models, ./service, ./version
|
||||||
|
|
||||||
const JSON = "application/json"
|
const JSON = "application/json"
|
||||||
@ -75,6 +77,44 @@ proc fromJWT*(ctx: PMApiContext, strTok: string): Session =
|
|||||||
issuedAt: fromUnix(jwt.claims["iat"].node.num),
|
issuedAt: fromUnix(jwt.claims["iat"].node.num),
|
||||||
expires: fromUnix(jwt.claims["exp"].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 =
|
proc extractSession(ctx: PMApiContext, request: Request): Session =
|
||||||
## Helper to extract a session from a reqest.
|
## 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"):
|
if not request.headers.hasKey("Authorization"):
|
||||||
raiseEx "No auth token."
|
raiseEx "No auth token."
|
||||||
|
|
||||||
# Read and verify the JWT token
|
|
||||||
let headerVal = request.headers["Authorization"]
|
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 =
|
proc makeAuthToken*(ctx: PMApiContext, email, pwd: string): string =
|
||||||
## Given a user's email and pwd, validate the combination and generate a JWT
|
## 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
|
# find the user record
|
||||||
var user: User
|
var user: User
|
||||||
try:
|
try:
|
||||||
let users = ctx.db.getUserByEmail(email)
|
let users = ctx.db.getUsersByEmail(email)
|
||||||
if users.len != 1: raiseEx ""
|
if users.len != 1: raiseEx ""
|
||||||
user = users[0]
|
user = users[0]
|
||||||
except: raiseEx "invalid username or password"
|
except: raiseEx "invalid username or password"
|
||||||
@ -155,17 +198,10 @@ proc start*(ctx: PMApiContext): void =
|
|||||||
|
|
||||||
resp(Http200, $(%session.user), JSON)
|
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":
|
get "/api-tokens":
|
||||||
checkAuth()
|
checkAuth()
|
||||||
|
|
||||||
resp(Http200, $(%ctx.db.getApiTokenByUserId($session.user.id)))
|
resp(Http200, $(%ctx.db.getApiTokensByUserId($session.user.id)))
|
||||||
|
|
||||||
post "/api-tokens":
|
post "/api-tokens":
|
||||||
checkAuth()
|
checkAuth()
|
||||||
@ -174,7 +210,6 @@ proc start*(ctx: PMApiContext): void =
|
|||||||
try:
|
try:
|
||||||
let jsonBody = parseJson(request.body)
|
let jsonBody = parseJson(request.body)
|
||||||
newToken = ApiToken(
|
newToken = ApiToken(
|
||||||
id: genUUID(),
|
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
name: jsonBody["name"].getStr,
|
name: jsonBody["name"].getStr,
|
||||||
expires: none[DateTime](),
|
expires: none[DateTime](),
|
||||||
@ -182,12 +217,32 @@ proc start*(ctx: PMApiContext): void =
|
|||||||
except: jsonResp(Http400)
|
except: jsonResp(Http400)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
let tokenValue = "" # TODO
|
let tokenValue = randomString(ctx.cfg.pwdCost)
|
||||||
newToken.hashedToken = hashPwd(tokenValue)
|
newToken.hashedToken = hashPwd(tokenValue, session.user.salt)
|
||||||
ctx.db.createApiToken(token)
|
newToken = ctx.db.createApiToken(newToken)
|
||||||
let respToken = %newToken
|
let respToken = %newToken
|
||||||
newToken["value"] = tokenValue
|
respToken["value"] = %tokenValue
|
||||||
resp($newToken, JSON)
|
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)
|
waitFor(stopFuture)
|
||||||
|
|
||||||
|
@ -8,7 +8,7 @@ type
|
|||||||
dbConnString*: string
|
dbConnString*: string
|
||||||
debug*: bool
|
debug*: bool
|
||||||
port*: int
|
port*: int
|
||||||
pwdCost*: int
|
pwdCost*: int8
|
||||||
|
|
||||||
PMApiContext* = object
|
PMApiContext* = object
|
||||||
cfg*: PMApiConfig
|
cfg*: PMApiConfig
|
||||||
|
@ -12,9 +12,10 @@ type
|
|||||||
proc connect*(connString: string): PMApiDb =
|
proc connect*(connString: string): PMApiDb =
|
||||||
result = PMApiDb(conn: open("", "", "", connString))
|
result = PMApiDb(conn: open("", "", "", connString))
|
||||||
|
|
||||||
generateProcsForModels([User, ApiToken, Measure, Value])
|
generateProcsForModels([User, ApiToken, Measure, Measurement])
|
||||||
|
|
||||||
generateLookup(User, @["email"])
|
generateLookup(User, @["email"])
|
||||||
generateLookup(ApiToken, @["userId"])
|
generateLookup(ApiToken, @["userId"])
|
||||||
|
generateLookup(ApiToken, @["hashedToken"])
|
||||||
generateLookup(Measure, @["userId"])
|
generateLookup(Measure, @["userId"])
|
||||||
generateLookup(Value, @["measureId"])
|
generateLookup(Measurement, @["measureId"])
|
||||||
|
@ -37,6 +37,12 @@ proc updateRecord*[T](db: DbConn, rec: T): bool =
|
|||||||
|
|
||||||
return numRowsUpdated > 0;
|
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 =
|
template getRecord*(db: DbConn, modelType: type, id: UUID): untyped =
|
||||||
let row = db.getRow(sql(
|
let row = db.getRow(sql(
|
||||||
"SELECT " & columnNamesForModel(modelType).join(",") &
|
"SELECT " & columnNamesForModel(modelType).join(",") &
|
||||||
@ -79,6 +85,7 @@ macro generateProcsForModels*(modelTypes: openarray[type]): untyped =
|
|||||||
let getWhereName = ident("get" & modelName & "sWhere")
|
let getWhereName = ident("get" & modelName & "sWhere")
|
||||||
let createName = ident("create" & modelName)
|
let createName = ident("create" & modelName)
|
||||||
let updateName = ident("update" & modelName)
|
let updateName = ident("update" & modelName)
|
||||||
|
let deleteName = ident("delete" & modelName)
|
||||||
result.add quote do:
|
result.add quote do:
|
||||||
proc `getName`*(db: PMApiDb, id: UUID): `t` = getRecord(db.conn, `t`, id)
|
proc `getName`*(db: PMApiDb, id: UUID): `t` = getRecord(db.conn, `t`, id)
|
||||||
proc `getAllName`*(db: PMApiDb): seq[`t`] = getAllRecords(db.conn, `t`)
|
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)
|
return getRecordsWhere(db.conn, `t`, whereClause, values)
|
||||||
proc `createName`*(db: PMApiDb, rec: `t`): `t` = createRecord(db.conn, rec)
|
proc `createName`*(db: PMApiDb, rec: `t`): `t` = createRecord(db.conn, rec)
|
||||||
proc `updateName`*(db: PMApiDb, rec: `t`): bool = updateRecord(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 =
|
macro generateLookup*(modelType: type, fields: seq[string]): untyped =
|
||||||
let fieldNames = fields[1].mapIt($it)
|
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
|
# Create proc skeleton
|
||||||
result = quote do:
|
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
|
# Add dynamic parameters for the proc definition and inner proc call
|
||||||
for n in fieldNames:
|
for n in fieldNames:
|
||||||
let paramTuple = newNimNode(nnkPar)
|
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)))
|
paramTuple.add(newColonExpr(ident("value"), ident(n)))
|
||||||
|
|
||||||
result[3].add(newIdentDefs(ident(n), ident("string")))
|
result[3].add(newIdentDefs(ident(n), ident("string")))
|
||||||
|
@ -215,7 +215,7 @@ macro populateMutateClauses*(t: typed, newRecord: bool, mc: var MutateClauses):
|
|||||||
if `newRecord` and not `t`.id.isZero:
|
if `newRecord` and not `t`.id.isZero:
|
||||||
raise newException(
|
raise newException(
|
||||||
AssertionError,
|
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
|
# if we're looking at an optional field, add logic to check for presence
|
||||||
elif fieldType.kind == nnkBracketExpr and
|
elif fieldType.kind == nnkBracketExpr and
|
||||||
|
@ -6,6 +6,7 @@ type
|
|||||||
displayName*: string
|
displayName*: string
|
||||||
email*: string
|
email*: string
|
||||||
hashedPwd*: string
|
hashedPwd*: string
|
||||||
|
salt*: string
|
||||||
|
|
||||||
ApiToken* = object
|
ApiToken* = object
|
||||||
id*: UUID
|
id*: UUID
|
||||||
@ -26,7 +27,7 @@ type
|
|||||||
rangeUnits*: string
|
rangeUnits*: string
|
||||||
analysis*: seq[string]
|
analysis*: seq[string]
|
||||||
|
|
||||||
Value* = object
|
Measurement* = object
|
||||||
id*: UUID
|
id*: UUID
|
||||||
measureId*: UUID
|
measureId*: UUID
|
||||||
value*: int
|
value*: int
|
||||||
@ -44,8 +45,8 @@ proc `$`*(tok: ApiToken): string =
|
|||||||
proc `$`*(m: Measure): string =
|
proc `$`*(m: Measure): string =
|
||||||
return "Measure " & ($m.id)[0..6] & " - " & m.slug
|
return "Measure " & ($m.id)[0..6] & " - " & m.slug
|
||||||
|
|
||||||
proc `$`*(v: Value): string =
|
proc `$`*(v: Measurement): string =
|
||||||
return "Value " & ($v.id)[0..6] & " - " & ($v.measureId)[0..6] & " = " & $v.value
|
return "Measurement " & ($v.id)[0..6] & " - " & ($v.measureId)[0..6] & " = " & $v.value
|
||||||
|
|
||||||
proc `%`*(u: User): JsonNode =
|
proc `%`*(u: User): JsonNode =
|
||||||
result = %*{
|
result = %*{
|
||||||
@ -79,7 +80,7 @@ proc `%`*(m: Measure): JsonNode =
|
|||||||
if m.domainSource.isSome: result["domainSource"] = %(m.domainSource.get)
|
if m.domainSource.isSome: result["domainSource"] = %(m.domainSource.get)
|
||||||
if m.rangeSource.isSome: result["rangeSource"] = %(m.rangeSource.get)
|
if m.rangeSource.isSome: result["rangeSource"] = %(m.rangeSource.get)
|
||||||
|
|
||||||
proc `%`*(v: Value): JsonNode =
|
proc `%`*(v: Measurement): JsonNode =
|
||||||
result = %*{
|
result = %*{
|
||||||
"id": $v.id,
|
"id": $v.id,
|
||||||
"measureId": $v.measureId,
|
"measureId": $v.measureId,
|
||||||
|
@ -4,11 +4,16 @@ import ./configuration
|
|||||||
import ./db
|
import ./db
|
||||||
import ./models
|
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 =
|
proc hashPwd*(pwd: string, cost: int8): string =
|
||||||
let salt = genSalt(cost)
|
let salt = genSalt(cost)
|
||||||
result = hash(pwd, salt)
|
result = hash(pwd, salt)
|
||||||
|
|
||||||
proc validatePwd*(u: User, givenPwd: string): bool =
|
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))
|
result = compare(u.hashedPwd, hash(givenPwd, salt))
|
||||||
|
|
||||||
|
@ -5,7 +5,8 @@ create table "users" (
|
|||||||
id uuid default uuid_generate_v4() primary key,
|
id uuid default uuid_generate_v4() primary key,
|
||||||
display_name varchar not null,
|
display_name varchar not null,
|
||||||
email varchar not null unique,
|
email varchar not null unique,
|
||||||
hashed_pwd varchar not null
|
hashed_pwd varchar not null,
|
||||||
|
salt varchar not null
|
||||||
);
|
);
|
||||||
|
|
||||||
create table "api_tokens" (
|
create table "api_tokens" (
|
||||||
|
Loading…
x
Reference in New Issue
Block a user