681 lines
22 KiB
Nim

import asyncdispatch, base64, jester, json, jwt, logging, options, sequtils,
times, uuids
from httpcore import HttpMethod
from unicode import capitalize
import strutils except capitalize
import timeutils
import ./db, ./configuration, ./models, ./service, ./version
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) =
## 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, body: string = "", headersToSend: RawHeaders = @{:} ) =
let reqOrigin =
if request.headers.hasKey("Origin"): $(request.headers["Origin"])
else: ""
let corsHeaders =
if ctx.cfg.knownOrigins.contains(reqOrigin):
@{
"Access-Control-Allow-Origin": reqOrigin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": $(request.reqMethod),
"Access-Control-Allow-Headers": "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
}
else: @{:}
halt(
code,
headersToSend & corsHeaders & @{
"Content-Type": JSON,
"Cache-Control": "no-cache"
},
body
)
template optionsResp(allowedMethods: seq[HttpMethod]) =
let reqOrigin =
if request.headers.hasKey("Origin"): $(request.headers["Origin"])
else: ""
let corsHeaders =
if ctx.cfg.knownOrigins.contains(reqOrigin):
@{
"Access-Control-Allow-Origin": reqOrigin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": allowedMethods.mapIt($it).join(", "),
"Access-Control-Allow-Headers": "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
}
else: @{:}
halt(
Http200,
corsHeaders,
""
)
template jsonResp(body: string) = jsonResp(Http200, body)
template statusResp(code: HttpCode, details: string = "", headersToSend: RawHeaders = @{:} ) =
jsonResp(
code,
$(%* {
"statusCode": code.int,
"status": $code,
"details": details
}),
headersToSend)
# internal JSON parsing utils
proc getIfExists(n: JsonNode, key: string): JsonNode =
## convenience method to get a key from a JObject or return null
result = if n.hasKey(key): n[key]
else: newJNull()
proc getOrFail(n: JsonNode, key: string, objName: string = ""): JsonNode =
## convenience method to get a key from a JObject or raise an exception
if not n.hasKey(key): raiseEx BadRequestError, objName & " missing key '" & key & "'"
return n[key]
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, HS256): 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 fromApiToken*(ctx: PMApiContext, strTok: string): Session =
let pairs = strTok.decode.split(":")
let email = pairs[0]
let apiTokVal = pairs[1]
# Look up the user
var user: User
try:
let users = ctx.db.findUsersByEmail(email)
if users.len != 1: raiseEx ""
user = users[0]
except: raiseEx "invalid username or password"
# look for the ApiToken record, hashing the token provided with the user's salt
let hashed = hashWithSalt(apiTokVal, user.salt)
let foundTokens = ctx.db.findApiTokensByHashedToken(hashed.hash)
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.
# Find the auth header
if not request.headers.hasKey("Authorization"):
raiseEx "No auth token."
let headerVal = request.headers["Authorization"]
# 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
## token string.
if email.len == 0 or pwd.len == 0:
raiseEx AuthError, "fields 'username' and 'password' required"
# find the user record
var user: User
try:
let users = ctx.db.findUsersByEmail(email)
if users.len != 1: raiseEx ""
user = users[0]
except:
error "unable to find user", getCurrentExceptionMsg()
raiseEx AuthError, "invalid username or password"
if not validatePwd(user, pwd): raiseEx AuthError, "invalid username or password"
let session = newSession(user)
result = toJWT(ctx, session)
template checkAuth(requiresAdmin = false) =
## 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(ctx, request)
except:
debug "Auth failed: " & getCurrentExceptionMsg()
statusResp(Http401, "Unauthorized", @{"WWW-Authenticate": "Bearer"})
if requiresAdmin and not session.user.isAdmin:
statusResp(Http401, "Unauthorized", @{"WWW-Authenticate": "Bearer"})
proc start*(ctx: PMApiContext): void =
if ctx.cfg.debug: setLogFilter(lvlDebug)
var stopFuture = newFuture[void]()
settings:
port = Port(ctx.cfg.port)
appName = "/v0"
routes:
options "/version": optionsResp(@[HttpGet])
get "/version":
jsonResp($(%("personal_measure_api v" & PM_API_VERSION)))
options "/auth-token": optionsResp(@[HttpPost])
post "/auth-token":
try:
let jsonBody = parseJson(request.body)
let email = jsonBody.getOrFail("email").getStr
let pwd = jsonBody.getOrFail("password").getStr
let authToken = makeAuthToken(ctx, email, pwd)
jsonResp($(%authToken))
except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except: statusResp(Http401, getCurrentExceptionMsg())
options "/change-pwd": optionsResp(@[HttpPost])
post "/change-pwd":
checkAuth()
try:
let jsonBody = parseJson(request.body)
if not validatePwd(session.user, jsonBody.getOrFail("oldPassword").getStr):
raiseEx AuthError, "old password is incorrect"
let newHash = hashWithSalt(jsonBody.getOrFail("newPassword").getStr, session.user.salt)
session.user.hashedPwd = newHash.hash
if ctx.db.updateUser(session.user): statusResp(Http200)
else: statusResp(Http500, "unable to change pwd")
except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except AuthError: statusResp(Http401, getCurrentExceptionMsg())
except:
error "internal error changing password: " & getCurrentExceptionMsg()
statusResp(Http500)
options "/change-pwd/@userId": optionsResp(@[HttpPost])
post "/change-pwd/@userId":
checkAuth(true)
try:
let jsonBody = parseJson(request.body)
var user = ctx.db.getUser(parseUUID(@"userId"))
let newHash = hashWithSalt(jsonBody.getOrFail("newPassword").getStr, user.salt)
user.hashedPwd = newHash.hash
if ctx.db.updateUser(user): statusResp(Http200)
else: statusResp(Http500, "unable to change pwd")
except ValueError: statusResp(Http400, "invalid UUID")
except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except AuthError: statusResp(Http401, getCurrentExceptionMsg())
except NotFoundError: statusResp(Http404, "no such user")
except:
error "internal error changing password: " & getCurrentExceptionMsg()
statusResp(Http500)
options "/user": optionsResp(@[HttpGet, HttpPut])
get "/user":
checkAuth()
jsonResp($(%session.user))
put "/user":
checkAuth()
try:
let jsonBody = parseJson(request.body)
var updatedUser = session.user
# if jsonBody.hasKey("email") # TODO: add verification?
if jsonBody.hasKey("displayName"):
updatedUser.displayName = jsonBody["displayName"].getStr()
statusResp(Http200, $(%ctx.db.updateUser(updatedUser)))
except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except:
error "Could not update user information:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
options "/users": optionsResp(@[HttpGet, HttpPost])
get "/users":
checkAuth(true)
jsonResp($(%ctx.db.getAllUsers()))
post "/users":
checkAuth(true)
try:
let jsonBody = parseJson(request.body)
let pwdAndSalt = jsonBody.getOrFail("password").getStr.hashPwd(ctx.cfg.pwdCost)
let newUser = User(
displayName: jsonBody.getOrFail("displayName").getStr,
email: jsonBody.getOrFail("email").getStr,
hashedPwd: pwdAndSalt.pwd,
salt: pwdAndSalt.salt,
isAdmin: false)
jsonResp($(%ctx.db.createUser(newUser)))
except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except:
error "Could not create new user:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
options "/users/@userId": optionsResp(@[HttpGet, HttpDelete])
get "/users/@userId":
checkAuth(true)
jsonResp($(%ctx.db.getUser(parseUUID(@"userId"))))
delete "/users/@userId":
checkAuth(true)
var user: User
try:
let userId = parseUUID(@"userId")
user = ctx.db.getUser(userId)
except: statusResp(Http404)
try:
if not ctx.db.deleteUser(user): raiseEx "unable to delete user"
statusResp(Http200, "user " & user.email & " deleted")
except: statusResp(Http500, getCurrentExceptionMsg())
options "/api-tokens": optionsResp(@[HttpGet, HttpPost])
get "/api-tokens":
checkAuth()
jsonResp($(%ctx.db.findApiTokensByUserId($session.user.id)))
post "/api-tokens":
checkAuth()
try:
let jsonBody = parseJson(request.body)
var newToken = ApiToken(
userId: session.user.id,
name: jsonBody.getOrFail("name").getStr,
created: getTime().utc,
expires: none[DateTime](),
hashedToken: "")
let tokenValue = randomString(ctx.cfg.pwdCost)
let hashed = hashWithSalt(tokenValue, session.user.salt)
newToken.hashedToken = hashed.hash
newToken = ctx.db.createApiToken(newToken)
let respToken = %newToken
respToken["value"] = %tokenValue
jsonResp($respToken)
except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except AuthError: statusResp(Http401, getCurrentExceptionMsg())
except:
debug getCurrentExceptionMsg()
statusResp(Http500)
options "/api-tokens/@tokenId": optionsResp(@[HttpGet, HttpDelete])
get "/api-tokens/@tokenId":
checkAuth()
try:
jsonResp($(%ctx.db.getApiToken(parseUUID(@"tokenId"))))
except NotFoundError: statusResp(Http404, getCurrentExceptionMsg())
except: statusResp(Http500)
delete "/api-tokens/@tokenId":
checkAuth()
try:
let token = ctx.db.getApiToken(parseUUID(@"tokenId"))
if ctx.db.deleteApiToken(token): statusResp(Http200)
else: statusResp(Http500)
except NotFoundError: statusResp(Http404, getCurrentExceptionMsg())
except: statusResp(Http500)
# Measure
options "/measures": optionsResp(@[HttpGet, HttpPost])
get "/measures":
checkAuth()
try: jsonResp($(%ctx.db.findMeasuresByUserId($session.user.id)))
except:
error "unable to retrieve measures for user:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
post "/measures":
checkAuth()
try:
let jsonBody = parseJson(request.body)
if not (jsonBody.hasKey("slug") or jsonBody.hasKey("name")):
raiseEx BadRequestError, "body must contain either the 'slug' field (short name), or the 'name' field, or both"
let slug =
if jsonBody.hasKey("slug"): jsonBody["slug"].getStr.nameToSlug
else: jsonBody["name"].getStr.nameToSlug
let name =
if jsonBody.hasKey("name"): jsonBody["name"].getStr
else: jsonBody["slug"].getStr.capitalize
let config =
if jsonBody.hasKey("config"): jsonBody["config"]
else: newJObject()
var newMeasure = Measure(
userId: session.user.id,
slug: slug,
name: name,
description: jsonBody.getIfExists("description").getStr(""),
config: config)
jsonResp($(%ctx.db.createMeasure(newMeasure)))
except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except:
error "unable to create new measure:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
options "/measures/@slug": optionsResp(@[HttpGet, HttpPost, HttpDelete])
get "/measures/@slug":
checkAuth()
try: jsonResp($(%ctx.getMeasureForSlug(session.user.id, @"slug")))
except NotFoundError: statusResp(Http404, getCurrentExceptionMsg())
except:
error "unable to look up a measure by id:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
post "/measures/@slug":
checkAuth()
try:
let jsonBody = parseJson(request.body)
var existingMeasure = ctx.getMeasureForSlug(session.user.id, @"slug")
if not (jsonBody.hasKey("slug") or jsonBody.hasKey("name")):
raiseEx BadRequestError, "body must contain either the 'slug' field (short name), or the 'name' field, or both"
existingMeasure.slug =
if jsonBody.hasKey("slug"): jsonBody["slug"].getStr.nameToSlug
else: jsonBody["name"].getStr.nameToSlug
existingMeasure.name =
if jsonBody.hasKey("name"): jsonBody["name"].getStr
else: jsonBody["slug"].getStr.capitalize
if jsonBody.hasKey("config"): existingMeasure.config = jsonBody["config"]
if jsonBody.hasKey("description"): existingMeasure.description = jsonBody["description"].getStr
jsonResp($(%ctx.db.updateMeasure(existingMeasure)))
except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except:
error "unable to update measure:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
delete "/measures/@slug":
checkAuth()
try:
let measure = ctx.getMeasureForSlug(session.user.id, @"slug")
if ctx.db.deleteMeasure(measure): statusResp(Http200)
else: raiseEx ""
except NotFoundError: statusResp(Http404, getCurrentExceptionMsg())
except:
error "unable to delete a measure:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
# Measurements
options "/measurements/@slug": optionsResp(@[HttpGet, HttpPost])
get "/measurements/@slug":
checkAuth()
try:
let measure = ctx.getMeasureForSlug(session.user.id, @"slug")
jsonResp($(%ctx.db.findMeasurementsByMeasureId($measure.id)))
except NotFoundError: statusResp(Http404, getCurrentExceptionMsg())
except:
error "unable to list measurements:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
post "/measurements/@slug":
checkAuth()
try:
let measure = ctx.getMeasureForSlug(session.user.id, @"slug")
let jsonBody = parseJson(request.body)
let newMeasurement = Measurement(
measureId: measure.id,
value: jsonBody.getOrFail("value").getFloat,
timestamp:
if jsonBody.hasKey("timestamp"): jsonBody["timestamp"].getStr.parseIso8601.utc
else: getTime().utc,
extData:
if jsonBody.hasKey("extData"): jsonBody["extData"]
else: newJObject())
jsonResp($(%ctx.db.createMeasurement(newMeasurement)))
except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except NotFoundError: statusResp(Http404, getCurrentExceptionMsg())
except:
error "unable to add measurement:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
options "/measurements/@slug/@id": optionsResp(@[HttpGet, HttpPut, HttpDelete])
get "/measurements/@slug/@id":
checkAuth()
try:
let measure = ctx.getMeasureForSlug(session.user.id, @"slug")
jsonResp($(%ctx.getMeasurementForMeasure(measure.id, parseUUID(@"id"))))
except ValueError: statusResp(Http400, getCurrentExceptionMsg())
except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except NotFoundError: statusResp(Http404, getCurrentExceptionMsg())
except:
error "unable to retrieve measurement:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
put "/measurements/@slug/@id":
checkAuth()
try:
let measure = ctx.getMeasureForSlug(session.user.id, @"slug")
var measurement = ctx.getMeasurementForMeasure(measure.id, parseUUID(@"id"))
let jsonBody = parseJson(request.body)
if jsonBody.hasKey("value"): measurement.value = jsonBody["value"].getFloat
if jsonBody.hasKey("timestamp"): measurement.timestamp = jsonBody["timestamp"].getStr.parseIso8601
if jsonBody.hasKey("extData"): measurement.extData = jsonBody["extData"]
jsonResp($(%ctx.db.updateMeasurement(measurement)))
except ValueError: statusResp(Http400, getCurrentExceptionMsg())
except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except NotFoundError: statusResp(Http404, getCurrentExceptionMsg())
except:
error "unable to retrieve measurement:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
delete "/measurements/@slug/@id":
checkAuth()
try:
let measure = ctx.getMeasureForSlug(session.user.id, @"slug")
let measurement = ctx.getMeasurementForMeasure(measure.id, parseUUID(@"id"))
if ctx.db.deleteMeasurement(measurement): statusResp(Http200)
else: raiseEx ""
except ValueError: statusResp(Http400, getCurrentExceptionMsg())
except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except NotFoundError: statusResp(Http404, getCurrentExceptionMsg())
except:
error "unable to delete measurement:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
options "/log": optionsResp(@[HttpPost])
post "/log":
checkAuth()
try:
let jsonBody = parseJson(request.body)
let logEntry = ClientLogEntry(
userId: session.user.id,
level: jsonBody.getOrFail("level").getStr,
message: jsonBody.getOrFail("message").getStr,
scope: jsonBody.getOrFail("scope").getStr,
stacktrace: jsonBody.getIfExists("stacktrace").getStr(""),
timestamp: jsonBody.getOrFail("timestamp").getStr.parseIso8601
)
jsonResp($(%ctx.db.createClientLogEntry(logEntry)))
except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except: statusResp(Http500, getCurrentExceptionMsg())
options "/log/batch": optionsResp(@[HttpPost])
post "/log/batch":
checkAuth()
try:
let jsonBody = parseJson(request.body);
let respMsgs = jsonBody.getElems.mapIt(
ClientLogEntry(
userId: session.user.id,
level: it.getOrFail("level").getStr,
message: it.getOrFail("message").getStr,
scope: it.getOrFail("scope").getStr,
stacktrace: it.getIfExists("stacktrace").getStr(""),
timestamp: it.getOrFail("timestamp").getStr.parseIso8601
))
jsonResp($(%respMsgs))
except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except: statusResp(Http500, getCurrentExceptionMsg())
post "/service/debug/stop":
if not ctx.cfg.debug: statusResp(Http404)
else:
let shutdownFut = sleepAsync(100)
shutdownFut.callback = proc(): void = complete(stopFuture)
jsonResp($(%"shutting down"))
waitFor(stopFuture)