WIP Refactor API into multiple sub-modules.
This commit is contained in:
		
							
								
								
									
										7
									
								
								api/personal_measure.config.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								api/personal_measure.config.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "authSecret":"change me",
 | 
				
			||||||
 | 
					  "dbConnString":"host=localhost port=5500 dbname=personal_measure user=postgres password=password",
 | 
				
			||||||
 | 
					  "debug":true,
 | 
				
			||||||
 | 
					  "port":8080,
 | 
				
			||||||
 | 
					  "pwdCost":11
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -8,9 +8,11 @@ description   = "JDB\'s Personal Measures API"
 | 
				
			|||||||
license       = "MIT"
 | 
					license       = "MIT"
 | 
				
			||||||
srcDir        = "src/main/nim"
 | 
					srcDir        = "src/main/nim"
 | 
				
			||||||
bin           = @["personal_measure_api"]
 | 
					bin           = @["personal_measure_api"]
 | 
				
			||||||
 | 
					skipExt       = @["nim"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Dependencies
 | 
					# Dependencies
 | 
				
			||||||
 | 
					
 | 
				
			||||||
requires @["nim >= 0.19.4", "bcrypt", "docopt >= 0.6.8", "isaac >= 0.1.3", "jester >= 0.4.1", "jwt", "tempfile",
 | 
					requires @["nim >= 0.19.4", "bcrypt", "cliutils >= 0.6.3", "docopt >= 0.6.8",
 | 
				
			||||||
  "timeutils >= 0.4.0", "uuids >= 0.1.10" ]
 | 
					  "isaac >= 0.1.3", "jester >= 0.4.1", "jwt", "tempfile", "timeutils >= 0.4.0",
 | 
				
			||||||
 | 
					  "uuids >= 0.1.10" ]
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,192 +1,83 @@
 | 
				
			|||||||
import asyncdispatch, bcrypt, docopt, jester, json, jwt, options, times, timeutils
 | 
					import cliutils, docopt, logging, jester, json, os, strutils, tables
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import personal_measure_apipkg/models
 | 
					import personal_measure_apipkg/configuration
 | 
				
			||||||
import personal_measure_apipkg/db
 | 
					 | 
				
			||||||
import personal_measure_apipkg/version
 | 
					import personal_measure_apipkg/version
 | 
				
			||||||
 | 
					import personal_measure_apipkg/api
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const JSON = "application/json"
 | 
					const DEFAULT_CONFIG = PMApiConfig(
 | 
				
			||||||
 | 
					  authSecret: "change me",
 | 
				
			||||||
 | 
					  dbConnString: "",
 | 
				
			||||||
 | 
					  debug: false,
 | 
				
			||||||
 | 
					  port: 8080,
 | 
				
			||||||
 | 
					  pwdCost: 11)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type
 | 
					proc loadConfig*(args: Table[string, docopt.Value] = initTable[string, docopt.Value]()): PMApiConfig  =
 | 
				
			||||||
  PersonalMeasureApiConfig = object
 | 
					 | 
				
			||||||
    authSecret*: string
 | 
					 | 
				
			||||||
    dbConnString*: string
 | 
					 | 
				
			||||||
    debug*: bool
 | 
					 | 
				
			||||||
    port*: int
 | 
					 | 
				
			||||||
    pwdCost*: int
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Session = object
 | 
					  let filePath =
 | 
				
			||||||
    user*: User
 | 
					      if args["--config"]: $args["--config"]
 | 
				
			||||||
    issuedAt*, expires*: Time
 | 
					      else: "personal_measure.config.json"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
proc newSession*(user: User): Session =
 | 
					  var json: JsonNode
 | 
				
			||||||
  result = Session(
 | 
					  try: json = parseFile(filePath)
 | 
				
			||||||
    user: user,
 | 
					 | 
				
			||||||
    issuedAt: getTime().utc.trimNanoSec.toTime,
 | 
					 | 
				
			||||||
    expires: daysForward(1).trimNanoSec.toTime)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
proc raiseEx*(reason: string): void =
 | 
					 | 
				
			||||||
  raise newException(Exception, reason)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
template halt(code: HttpCode,
 | 
					 | 
				
			||||||
              headers: RawHeaders,
 | 
					 | 
				
			||||||
              content: string): typed =
 | 
					 | 
				
			||||||
  ## 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, details: string = "", headers: RawHeaders = @{:} ) =
 | 
					 | 
				
			||||||
  halt(
 | 
					 | 
				
			||||||
    code,
 | 
					 | 
				
			||||||
    headers & @{"Content-Type": JSON},
 | 
					 | 
				
			||||||
    $(%* {
 | 
					 | 
				
			||||||
      "statusCode": code.int,
 | 
					 | 
				
			||||||
      "status": $code,
 | 
					 | 
				
			||||||
      "details": details
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
  )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
template json500Resp(ex: ref Exception, details: string = ""): void =
 | 
					 | 
				
			||||||
  when not defined(release): debug ex.getStackTrace()
 | 
					 | 
				
			||||||
  error details & ":\n" & ex.msg
 | 
					 | 
				
			||||||
  jsonResp(Http500)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
proc toJWT*(cfg: PersonalMeasureApiConfig, 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(cfg.authSecret)
 | 
					 | 
				
			||||||
  result = $jwt
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
proc fromJWT*(cfg: PersonalMeasureApiConfig, strTok: string): Session =
 | 
					 | 
				
			||||||
  ## Validate a given JWT and extract the session data.
 | 
					 | 
				
			||||||
  let jwt = toJWT(strTok)
 | 
					 | 
				
			||||||
  var secret = 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
 | 
					 | 
				
			||||||
  let user = db.getUser(userId)
 | 
					 | 
				
			||||||
  if users.len != 1: raiseEx "Could not find session user."
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  result = Session(
 | 
					 | 
				
			||||||
    user: user,
 | 
					 | 
				
			||||||
    issuedAt: fromUnix(jwt.claims["iat"].node.num),
 | 
					 | 
				
			||||||
    expires: fromUnix(jwt.claims["exp"].node.num))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
proc extractSession(cfg: PersonalMeasureApiConfig, 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."
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  # 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(cfg, headerVal[7..^1])
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
proc hashPwd*(pwd: string, cost: int8): string =
 | 
					 | 
				
			||||||
  let salt = genSalt(cost)
 | 
					 | 
				
			||||||
  result = hash(pwd, salt)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
proc validatePwd*(u: ref User, givenPwd: string): bool =
 | 
					 | 
				
			||||||
  let salt = u.hashedPwd[0..28] # TODO: magic numbers
 | 
					 | 
				
			||||||
  result = compare(u.hashedPwd, hash(givenPwd, salt))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
proc makeAuthToken*(cfg: PersonalMeasureApiConfig, uname, pwd: string): string =
 | 
					 | 
				
			||||||
  ## Given a username and pwd, validate the combination and generate a JWT
 | 
					 | 
				
			||||||
  ## token string.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if uname.len == 0 or pwd.len == 0:
 | 
					 | 
				
			||||||
    raiseEx "fields 'username' and 'password' required"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  # find the user record
 | 
					 | 
				
			||||||
  let users = cfg.users.filterIt(it.name == uname)
 | 
					 | 
				
			||||||
  if users.len != 1: raiseEx "invalid username or password"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  let user = users[0]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if not validatePwd(user, pwd): raiseEx "invalid username or password"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  let session = newSession(user)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  result = toJWT(cfg, session)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
proc makeApiKey*(cfg: PersonalMeasureApiConfig, uname: string): string =
 | 
					 | 
				
			||||||
  ## Given a username, make an API token (JWT token string that does not
 | 
					 | 
				
			||||||
  ## expire). Note that this does not validate the username/pwd combination. It
 | 
					 | 
				
			||||||
  ## is not intended to be exposed publicly via the API, but serve as a utility
 | 
					 | 
				
			||||||
  ## function for an administrator to setup a unsupervised account (git access
 | 
					 | 
				
			||||||
  ## for example).
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if uname.len == 0: raiseEx "no username given"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  # find the user record
 | 
					 | 
				
			||||||
  let users = cfg.users.filterIt(it.name == uname)
 | 
					 | 
				
			||||||
  if users.len != 1: raiseEx "invalid username"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  let session = Session(
 | 
					 | 
				
			||||||
    user: users[0],
 | 
					 | 
				
			||||||
    issuedAt: getTime(),
 | 
					 | 
				
			||||||
    expires: daysForward(365 * 1000).toTime())
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  result = toJWT(cfg, session);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
template checkAuth() =
 | 
					 | 
				
			||||||
  ## 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(cfg, request)
 | 
					 | 
				
			||||||
  except:
 | 
					  except:
 | 
				
			||||||
    debug "Auth failed: " & getCurrentExceptionMsg()
 | 
					    json = %DEFAULT_CONFIG
 | 
				
			||||||
    jsonResp(Http401, "Unauthorized", @{"WWW-Authenticate": "Bearer"})
 | 
					    if not existsFile(filePath):
 | 
				
			||||||
 | 
					      info "created new configuration file \"" & filePath & "\""
 | 
				
			||||||
 | 
					      filePath.writeFile($json)
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					      warn "Cannot read configuration file \"" & filePath & "\":\n\t" &
 | 
				
			||||||
 | 
					        getCurrentExceptionMsg()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let cfg = CombinedConfig(docopt: args, json: json)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
proc start*(cfg: PersonalMeasureApiConfig): void =
 | 
					  result = PMApiConfig(
 | 
				
			||||||
 | 
					    authSecret: cfg.getVal("authSecret"),
 | 
				
			||||||
 | 
					    dbConnString: cfg.getVal("dbConnString"),
 | 
				
			||||||
 | 
					    debug: "true".startsWith(cfg.getVal("debug", "false").toLower()),
 | 
				
			||||||
 | 
					    port: parseInt(cfg.getVal("port", "8080")),
 | 
				
			||||||
 | 
					    pwdCost: parseInt(cfg.getVal("pwdCost", "11")))
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
  var stopFuture = newFuture[void]()
 | 
					proc initContext(args: Table[string, docopt.Value]): PMApiContext =
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  settings:
 | 
					  var cfg: PMApiConfig
 | 
				
			||||||
    port = Port(cfg.port)
 | 
					  var db: PMApiDb
 | 
				
			||||||
    appName = "/api"
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  routes:
 | 
					  try: cfg = loadConfig(args)
 | 
				
			||||||
 | 
					  except: raiseEx "Unable to load configuration: \n\t" & getCurrentExceptionMsg()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    get "/version":
 | 
					  try: db = connect(cfg.dbConnString)
 | 
				
			||||||
      resp($(%("personal_measure_api v" & PM_API_VERSION)), JSON)
 | 
					  except: raiseEx "Unable to connect to the database:\n\t" & getCurrentExceptionMsg()
 | 
				
			||||||
 | 
					 | 
				
			||||||
    post "/service/debug/stop":
 | 
					 | 
				
			||||||
      if not cfg.debug: jsonResp(Http404)
 | 
					 | 
				
			||||||
      else:
 | 
					 | 
				
			||||||
        let shutdownFut = sleepAsync(100)
 | 
					 | 
				
			||||||
        shutdownFut.callback = proc(): void = complete(stopFuture)
 | 
					 | 
				
			||||||
        resp($(%"shutting down"), JSON)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  waitFor(stopFuture)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  result = PMApiContext(cfg: cfg, db: db)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
when isMainModule:
 | 
					when isMainModule:
 | 
				
			||||||
  start(PersonalMeasureApiConfig(
 | 
					
 | 
				
			||||||
    debug: true,
 | 
					  try:
 | 
				
			||||||
    port: 8090,
 | 
					    let doc = """
 | 
				
			||||||
    pwdCost: 11,
 | 
					Usage:
 | 
				
			||||||
    dbConnString: "host=localhost port=5500 username=postgres password=password dbname=personal_measure"))
 | 
					  personal_measure_api test [options]
 | 
				
			||||||
 | 
					  personal_measure_api serve [options]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Options:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  -C, --config <cfgFile>    Location of the config file (defaults to personal_measure.config.json)
 | 
				
			||||||
 | 
					"""
 | 
				
			||||||
 | 
					    logging.addHandler(newConsoleLogger())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Initialize our service context
 | 
				
			||||||
 | 
					    let args = docopt(doc, version = PM_API_VERSION)
 | 
				
			||||||
 | 
					    let ctx = initContext(args)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if args["test"]:
 | 
				
			||||||
 | 
					      echo "Test"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if args["serve"]:
 | 
				
			||||||
 | 
					      start(PMApiConfig(
 | 
				
			||||||
 | 
					        debug: true,
 | 
				
			||||||
 | 
					        port: 8090,
 | 
				
			||||||
 | 
					        pwdCost: 11,
 | 
				
			||||||
 | 
					        dbConnString: "host=localhost port=5500 username=postgres password=password dbname=personal_measure"))
 | 
				
			||||||
 | 
					  except:
 | 
				
			||||||
 | 
					    fatal "pit: " & getCurrentExceptionMsg()
 | 
				
			||||||
 | 
					    #raise getCurrentException()
 | 
				
			||||||
 | 
					    quit(QuitFailure)
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										146
									
								
								api/src/main/nim/personal_measure_apipkg/api.nim
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								api/src/main/nim/personal_measure_apipkg/api.nim
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,146 @@
 | 
				
			|||||||
 | 
					import asyncdispatch, jester, json, jwt, strutils, times, timeutils, uuids
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import ./db
 | 
				
			||||||
 | 
					import ./configuration
 | 
				
			||||||
 | 
					import ./models
 | 
				
			||||||
 | 
					import ./service
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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): typed =
 | 
				
			||||||
 | 
					  ## 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, details: string = "", headers: RawHeaders = @{:} ) =
 | 
				
			||||||
 | 
					  halt(
 | 
				
			||||||
 | 
					    code,
 | 
				
			||||||
 | 
					    headers & @{"Content-Type": JSON},
 | 
				
			||||||
 | 
					    $(%* {
 | 
				
			||||||
 | 
					      "statusCode": code.int,
 | 
				
			||||||
 | 
					      "status": $code,
 | 
				
			||||||
 | 
					      "details": details
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					template json500Resp(ex: ref Exception, details: string = ""): void =
 | 
				
			||||||
 | 
					  when not defined(release): debug ex.getStackTrace()
 | 
				
			||||||
 | 
					  error details & ":\n" & ex.msg
 | 
				
			||||||
 | 
					  jsonResp(Http500)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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 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."
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  # 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])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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 "fields 'username' and 'password' required"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  # find the user record
 | 
				
			||||||
 | 
					  var user: User
 | 
				
			||||||
 | 
					  try: user = ctx.db.getUserByEmail(email)
 | 
				
			||||||
 | 
					  except: raiseEx "invalid username or password"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if not validatePwd(user, pwd): raiseEx "invalid username or password"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let session = newSession(user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  result = toJWT(ctx, session)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					template checkAuth() =
 | 
				
			||||||
 | 
					  ## 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(cfg, request)
 | 
				
			||||||
 | 
					  except:
 | 
				
			||||||
 | 
					    debug "Auth failed: " & getCurrentExceptionMsg()
 | 
				
			||||||
 | 
					    jsonResp(Http401, "Unauthorized", @{"WWW-Authenticate": "Bearer"})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					proc start*(cfg: PMApiConfig): void =
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  var stopFuture = newFuture[void]()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  settings:
 | 
				
			||||||
 | 
					    port = Port(cfg.port)
 | 
				
			||||||
 | 
					    appName = "/api"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  routes:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    get "/version":
 | 
				
			||||||
 | 
					      resp($(%("personal_measure_api v" & PM_API_VERSION)), JSON)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    post "/service/debug/stop":
 | 
				
			||||||
 | 
					      if not cfg.debug: jsonResp(Http404)
 | 
				
			||||||
 | 
					      else:
 | 
				
			||||||
 | 
					        let shutdownFut = sleepAsync(100)
 | 
				
			||||||
 | 
					        shutdownFut.callback = proc(): void = complete(stopFuture)
 | 
				
			||||||
 | 
					        resp($(%"shutting down"), JSON)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  waitFor(stopFuture)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										27
									
								
								api/src/main/nim/personal_measure_apipkg/configuration.nim
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								api/src/main/nim/personal_measure_apipkg/configuration.nim
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,27 @@
 | 
				
			|||||||
 | 
					import json
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import ./db
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type
 | 
				
			||||||
 | 
					  PMApiConfig* = object
 | 
				
			||||||
 | 
					    authSecret*: string
 | 
				
			||||||
 | 
					    dbConnString*: string
 | 
				
			||||||
 | 
					    debug*: bool
 | 
				
			||||||
 | 
					    port*: int
 | 
				
			||||||
 | 
					    pwdCost*: int
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  PMApiContext* = object
 | 
				
			||||||
 | 
					    cfg*: PMApiConfig
 | 
				
			||||||
 | 
					    db*: PMApiDb
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					proc `%`*(cfg: PMApiConfig): JsonNode =
 | 
				
			||||||
 | 
					  result = %* {
 | 
				
			||||||
 | 
					    "authSecret": cfg.authSecret,
 | 
				
			||||||
 | 
					    "dbConnString": cfg.dbConnString,
 | 
				
			||||||
 | 
					    "debug": cfg.debug,
 | 
				
			||||||
 | 
					    "port": cfg.port,
 | 
				
			||||||
 | 
					    "pwdCost": cfg.pwdCost }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					proc raiseEx*(reason: string): void =
 | 
				
			||||||
 | 
					  raise newException(Exception, reason)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -4,14 +4,32 @@ import db_postgres, macros, options, postgres, sequtils, strutils, times,
 | 
				
			|||||||
import ./models
 | 
					import ./models
 | 
				
			||||||
import ./db_common
 | 
					import ./db_common
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# proc create: Typed create methods for specific records
 | 
					type
 | 
				
			||||||
proc createUser*(db: DbConn, user: User): User = return db.createRecord(user)
 | 
					  PMApiDb* = ref object
 | 
				
			||||||
proc createApiToken*(db: DbConn, token: ApiToken): ApiToken = return db.createRecord(token)
 | 
					    conn: DbConn
 | 
				
			||||||
proc createMeasure*(db: DbConn, measure: Measure): Measure = return db.createRecord(measure)
 | 
					 | 
				
			||||||
proc createValue*(db: DbConn, value: Value): Value = return db.createRecord(value)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
proc getUser*(db: DbConn, id: UUID): User = return db.getRecord(User, id)
 | 
					  
 | 
				
			||||||
proc getUser*(db: DbConn, id: string): User = return db.getRecord(User, parseUUID(id))
 | 
					proc connect*(connString: string): PMApiDb =
 | 
				
			||||||
proc getApiToken*(db: DbConn, id: UUID): ApiToken = return db.getRecord(ApiToken, id)
 | 
					  result = PMApiDb(conn: open("", "", "", connString))
 | 
				
			||||||
proc getMeasure*(db: DbConn, id: UUID): Measure = return db.getRecord(Measure, id)
 | 
					
 | 
				
			||||||
proc getValue*(db: DbConn, id: UUID): Value = return db.getRecord(Value, id)
 | 
					macro makeGetRecord(modelType: type): untyped =
 | 
				
			||||||
 | 
					  echo modelType.getType.treeRepr
 | 
				
			||||||
 | 
					#[
 | 
				
			||||||
 | 
					  let procIdent = ident("get" & $modelType.getType[1])
 | 
				
			||||||
 | 
					  return quote do:
 | 
				
			||||||
 | 
					    proc `procIdent`*(db: PMApiDb, id: UUID): `modelType` = return db.conn.getRecord(`modelType`, id)
 | 
				
			||||||
 | 
					]#
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					proc createUser*(db: PMApiDb, user: User): User = return db.conn.createRecord(user)
 | 
				
			||||||
 | 
					proc createApiToken*(db: PMApiDb, token: ApiToken): ApiToken = return db.conn.createRecord(token)
 | 
				
			||||||
 | 
					proc createMeasure*(db: PMApiDb, measure: Measure): Measure = return db.conn.createRecord(measure)
 | 
				
			||||||
 | 
					proc createValue*(db: PMApiDb, value: Value): Value = return db.conn.createRecord(value)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					proc getUser*(db: PMApiDb, id: UUID): User = return db.conn.getRecord(User, id)
 | 
				
			||||||
 | 
					proc getUser*(db: PMApiDb, id: string): User = return db.conn.getRecord(User, parseUUID(id))
 | 
				
			||||||
 | 
					proc getApiToken*(db: PMApiDb, id: UUID): ApiToken = return db.conn.getRecord(ApiToken, id)
 | 
				
			||||||
 | 
					proc getMeasure*(db: PMApiDb, id: UUID): Measure = return db.conn.getRecord(Measure, id)
 | 
				
			||||||
 | 
					proc getValue*(db: PMApiDb, id: UUID): Value = return db.conn.getRecord(Value, id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#proc getUsersWhere*(db: PMApiDb, whereClause: string, values: varargs[string, dbFormat]): User =
 | 
				
			||||||
 | 
					#  return db.conn.getRecordsWhere(User, whereClause, values)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -40,8 +40,19 @@ template getRecord*(db: DbConn, modelType: type, id: UUID): untyped =
 | 
				
			|||||||
    "SELECT " & columnNamesForModel(modelType).join(",") &
 | 
					    "SELECT " & columnNamesForModel(modelType).join(",") &
 | 
				
			||||||
    " FROM " & tableName(modelType) &
 | 
					    " FROM " & tableName(modelType) &
 | 
				
			||||||
    " WHERE id = ?"), @[$id])
 | 
					    " WHERE id = ?"), @[$id])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if row.allIt(it.len == 0):
 | 
				
			||||||
 | 
					    raise newException(KeyError, "no record for id " & $id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  rowToModel(modelType, row)
 | 
					  rowToModel(modelType, row)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					template getRecordsWhere*(db: DbConn, modelType: type, whereClause: string, values: varargs[string, dbFormat]): untyped =
 | 
				
			||||||
 | 
					  db.getAllRows(sql(
 | 
				
			||||||
 | 
					    "SELECT " & columnNamesForModel(modelType).join(",") &
 | 
				
			||||||
 | 
					    " FROM " & tableName(modelType) &
 | 
				
			||||||
 | 
					    " WHERE " & whereClause), values)
 | 
				
			||||||
 | 
					    .mapIt(rowToModel(modelType, it))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
template getAllRecords*(db: DbConn, modelType: type): untyped =
 | 
					template getAllRecords*(db: DbConn, modelType: type): untyped =
 | 
				
			||||||
  db.getAllRows(sql(
 | 
					  db.getAllRows(sql(
 | 
				
			||||||
    "SELECT " & columnNamesForModel(modelType).join(",") &
 | 
					    "SELECT " & columnNamesForModel(modelType).join(",") &
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										14
									
								
								api/src/main/nim/personal_measure_apipkg/service.nim
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								api/src/main/nim/personal_measure_apipkg/service.nim
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,14 @@
 | 
				
			|||||||
 | 
					import bcrypt
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import ./configuration
 | 
				
			||||||
 | 
					import ./db
 | 
				
			||||||
 | 
					import ./models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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
 | 
				
			||||||
 | 
					  result = compare(u.hashedPwd, hash(givenPwd, salt))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		Reference in New Issue
	
	Block a user