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)