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 /user Given a valid auth token, return the user details.
|
||||
☐ GET /api-tokens List api tokens.
|
||||
☐ DELETE /api-tokens/<id> 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/<id> Delete a specific api token.
|
||||
☑ POST /api-tokens With a valid session, create a new api token.
|
||||
|
||||
Legend
|
||||
------
|
||||
|
@ -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 <password> [<cost>] [options]
|
||||
personal_measure_api hashpwd <password> [options]
|
||||
|
||||
Options:
|
||||
|
||||
-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())
|
||||
|
||||
# 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["<cost>"]: parseInt($args["<cost>"])
|
||||
else: 11
|
||||
|
||||
echo hashPwd($args["<password>"], cast[int8](cost))
|
||||
if args["--salt"]:
|
||||
echo hashPwd($args["<password>"], $args["--salt"])
|
||||
else:
|
||||
let cost = ctx.cfg.pwdCost
|
||||
echo hashPwd($args["<password>"], 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)
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -8,7 +8,7 @@ type
|
||||
dbConnString*: string
|
||||
debug*: bool
|
||||
port*: int
|
||||
pwdCost*: int
|
||||
pwdCost*: int8
|
||||
|
||||
PMApiContext* = object
|
||||
cfg*: PMApiConfig
|
||||
|
@ -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"])
|
||||
|
@ -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")))
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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))
|
||||
|
||||
|
@ -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" (
|
||||
|
Loading…
x
Reference in New Issue
Block a user