591 lines
20 KiB
Nim

import asyncdispatch, base64, jester, json, jwt, logging, options, sequtils,
strutils, times, uuids
from unicode import capitalize
import timeutils except `<`
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 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): 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:
get "/version":
jsonResp($(%("personal_measure_api v" & PM_API_VERSION)))
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())
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)
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)
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)
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)
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())
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)
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)
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)
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)
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)
get "/measure/@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 "/measure/@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").getInt,
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)
get "/measure/@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 "/measure/@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"].getInt
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 "/measure/@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)
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())
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)