From f018614bc49178ece08102263b10da86596b4ae5 Mon Sep 17 00:00:00 2001 From: Ludovic 'Archivist' Lagouardette Date: Mon, 18 Mar 2019 15:00:18 +0100 Subject: [PATCH] version 0.1.0 --- Dockerfile | 6 +- README.md | 5 +- auth_bench_udp.cr | 76 ++++++ auth_bench_ws.cr | 62 +++++ shard.lock | 18 +- shard.yml | 6 +- ...s_backend_spec.cr => authenticate_spec.cr} | 11 +- spec/spec_helper.cr | 2 +- src/authenticate/http/user.cr | 235 ++++++++++++------ src/authenticate/sqlite_kv.cr | 99 ++++++++ src/config.cr | 12 +- 11 files changed, 437 insertions(+), 95 deletions(-) create mode 100644 auth_bench_udp.cr create mode 100644 auth_bench_ws.cr rename spec/{sales_backend_spec.cr => authenticate_spec.cr} (94%) create mode 100644 src/authenticate/sqlite_kv.cr diff --git a/Dockerfile b/Dockerfile index 6a1bee9..77b10d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,13 @@ -FROM alpine:latest +FROM alpine:edge -RUN apk add --no-cache crystal shards openssl openssl-dev musl-dev libc6-compat +RUN apk add --no-cache crystal shards openssl openssl-dev musl-dev libc6-compat zlib-dev sqlite-dev +RUN crystal -v >&2 COPY . /opt/app/ RUN cd /opt/app && \ shards ENV KEMAL_ENV test + RUN cd /opt/app/ && crystal spec ENV KEMAL_ENV production diff --git a/README.md b/README.md index 934c22c..c5f9532 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# sales_backend +# authenticate -TODO: Write a description here +An authentication server that uses HTTP, Websockets and UDP ## Installation @@ -16,7 +16,6 @@ TODO: Write development instructions here ## Contributing -1. Fork it () 2. Create your feature branch (`git checkout -b my-new-feature`) 3. Commit your changes (`git commit -am 'Add some feature'`) 4. Push to the branch (`git push origin my-new-feature`) diff --git a/auth_bench_udp.cr b/auth_bench_udp.cr new file mode 100644 index 0000000..f221be4 --- /dev/null +++ b/auth_bench_udp.cr @@ -0,0 +1,76 @@ +require "socket" +require "socket/udp_socket" +require "json" + +total = 0 +min = 1000000000 +max = 0 +cnt_s = Atomic(Int32).new(0) +mutex_s = Mutex.new + +cnt_r = Atomic(Int32).new(0) +mutex_r = Mutex.new + +start = Time.now +duration=Time::Span.new(0,0,45) +endt = start+duration + +(1..10).each do + spawn do + soc = UDPSocket.new + soc.connect "localhost", 3000 + mutex = Mutex.new + + curr=Time.now + spawn do + curr2=Time.now + while endt+Time::Span.new(0,0,4)>curr2 + begin + message : String | Nil = nil + mutex.synchronize do + message, addr = soc.receive + end + mess = JSON.parse message.not_nil! + t = Time.now.to_unix_f - mess["curr"].as_f + mutex_r.synchronize do + min = Math.min(min, t) + max = Math.max(max, t) + total += t + cnt_r.add 1 + end + rescue + puts message + end + curr2=Time.now + Fiber.yield + end + + + end + + curr=Time.now + while endt+Time::Span.new(0,0,4)>curr + data = Hash{"user" => "dummy@example.org", "api_token" => "576231a1-18e6-4fbd-b058-b9d23fa1b456", "curr" => curr.to_unix_f}.to_json + mutex.synchronize do + soc.send data + end + cnt_s.add 1 + curr = Time.now + Fiber.yield + end + end +end + +scurr=Time.now + +while scurrcurr + data = Hash{"user" => "dummy@example.org", "api_token" => "576231a1-18e6-4fbd-b058-b9d23fa1b456", "curr" => curr.to_unix_f}.to_json + soc.send data + cnt_s.add 1 + curr = Time.now + end + end +end + +scurr=Time.now + +while scurr5 - user_file.tokens = user_file.tokens.not_nil!.last(5) - end - File.write(Statics.data_path+"user/"+user_file.email,user_file.to_json) + user = User.from_json context.request.body.not_nil! + user_file : User + token : UUID | Nil = nil + KVStore.access.transaction do |store| + begin + user_file = store.fetch! "user/"+user.email + rescue ex + halt context, status_code: 403, response: ex.to_s + end + if Crypto::Bcrypt::Password.new(user_file.password_hash.not_nil!) == user.password_hash.not_nil! + else + halt context, status_code: 403, response: "Invalid password" + end + if user_file.tokens.nil? + user_file.tokens = Array(UUID).new + end + token = UUID.random() + user_file.tokens.not_nil!<5 + user_file.tokens = user_file.tokens.not_nil!.last(5) + end + store.push "user/"+user.email, user_file.to_json + end context.response.content_type = "application/json" - token.to_json + token.not_nil!.to_json end -post "/logout" do |context| - user = User.from_json context.request.body.not_nil! - user_file = User.from_json File.read(Statics.data_path+"user/"+user.email) - user_file.tokens=user_file.tokens.not_nil!-user.tokens.not_nil! - File.write(Statics.data_path+"user/"+user_file.email,user_file.to_json) +get "/logout" do |context| + user = context.request.headers["user"] + + KVStore.access.transaction do |store| + user_file = store.fetch!("user/"+user) + user_file.tokens.not_nil!.delete(UUID.new(context.request.headers["api_token"])) + store.push "user/"+user, user_file.to_json + end context.response.content_type = "application/json" "OK".to_json end @@ -74,10 +126,12 @@ post "/logout-all" do |context| user = authenticate!(context.request.headers["user"],UUID.new(context.request.headers["api_token"])) rescue ex halt context, status_code: 403, response: ex.to_s - end - user_file = User.from_json File.read(Statics.data_path+"user/"+user.email) - user_file.tokens=Array(UUID).new - File.write(Statics.data_path+"user/"+user_file.email,user_file.to_json) + end + KVStore.access.transaction do |store| + user_file = store.fetch!("user/"+user.email) + user_file.tokens=Array(UUID).new + store.push "user/"+user_file.email, user_file.to_json + end context.response.content_type = "application/json" "OK".to_json end @@ -87,18 +141,24 @@ post "/user" do |context| ph = user.password_hash user.tokens = Array(UUID).new user.invoices = Array(Invoice).new - if ph.nil? - raise Exception.new("No password provided") + if ph.nil? + halt context, status_code: 401, response: "No password provided" else user.password_hash=Crypto::Bcrypt::Password.create(ph,cost: 12).to_s end if Statics.email_regex.match(user.email)==nil - raise Exception.new("Bad email address") - end - if File.exists?(Statics.data_path+"user/"+user.email) - raise Exception.new("Email address already in use") + halt context, status_code: 401, response: "Bad email address provided" end - File.write(Statics.data_path+"user/"+user.email,user.to_json) + begin + KVStore.access.transaction do |store| + if store.fetch("user/"+user.email)!=nil + raise IndexError.new + end + store.push "user/"+user.email, user.to_json + end + rescue ex + halt context, status_code: 401, response: "Email address already in use" + end context.response.content_type = "application/json" "OK".to_json end @@ -114,6 +174,17 @@ get "/user/tokens" do |context| user.tokens.to_json end +get "/user/authenticate" do |context| + user : User + begin + user = authenticate!(context.request.headers["user"],UUID.new(context.request.headers["api_token"])) + rescue ex + halt context, status_code: 403, response: ex.to_s + end + context.response.content_type = "application/json" + "OK".to_json +end + get "/user/address" do |context| user : User begin @@ -132,37 +203,43 @@ post "/user/address" do |context| rescue ex halt context, status_code: 403, response: ex.to_s end - addresses = Array(Address).from_json(context.request.body.not_nil!).not_nil! - user_file = User.from_json File.read(Statics.data_path+"user/"+user.email) - old_list=user_file.addresses - if old_list.nil? - else - addresses=old_list+addresses - end - user_file.addresses=addresses - File.write(Statics.data_path+"user/"+user.email,user_file.to_json) + addresses = Array(Address).from_json(context.request.body.not_nil!).not_nil! + + KVStore.access.transaction do |store| + user_file = store.fetch!("user/"+user.email) + old_list=user_file.addresses + if old_list.nil? + else + addresses=old_list+addresses + end + user_file.addresses=addresses + store.push "user/"+user_file.email, user_file.to_json + end context.response.content_type = "application/json" "OK".to_json end delete "/user/address" do |context| user = authenticate!(context.request.headers["user"],UUID.new(context.request.headers["api_token"])) - addresses = Array(Address).from_json(context.request.body.not_nil!).not_nil! - user_file = User.from_json File.read(Statics.data_path+"user/"+user.email) - old_list=user_file.addresses - if old_list.nil? - addresses=Array(Address).new - else - addresses=old_list.select do |v| - isin=false - addresses.each do |va| - isin |= v==va - end - !isin - end - end - user_file.addresses=addresses - File.write(Statics.data_path+"user/"+user.email,user_file.to_json) + addresses = Array(Address).from_json(context.request.body.not_nil!).not_nil! + + KVStore.access.transaction do |store| + user_file = store.fetch!("user/"+user.email) + old_list=user_file.addresses + if old_list.nil? + addresses=Array(Address).new + else + addresses=old_list.select do |v| + isin=false + addresses.each do |va| + isin |= v==va + end + !isin + end + end + user_file.addresses=addresses + store.push "user/"+user_file.email, user_file.to_json + end context.response.content_type = "application/json" "OK".to_json end @@ -171,9 +248,17 @@ get "/user" do |context| context.response.content_type = "application/json" user : User | Nil begin - user = authenticate!(context.request.headers["user"],UUID.new(context.request.headers["api_token"])) - rescue ex - halt context, status_code: 403, response: ex.to_s + user = authenticate!(context.request.headers["user"],UUID.new(context.request.headers["api_token"])) + rescue ex + resp = String::Builder.build do |builder| + if ENV["KEMAL_ENV"] == "test" + ex.inspect_with_backtrace builder + else + ex.to_s builder + end + end + halt context, status_code: 403, response: resp end user.not_nil! end + diff --git a/src/authenticate/sqlite_kv.cr b/src/authenticate/sqlite_kv.cr new file mode 100644 index 0000000..19eda11 --- /dev/null +++ b/src/authenticate/sqlite_kv.cr @@ -0,0 +1,99 @@ +require "sqlite3" +require "../config.cr" + +class CacheLRU + def initialize + @store = Hash(String, Tuple(Time, User)).new + @mutex = Mutex.new + @cache_limit = 5000 + end + + def try(pair) : User | Nil + @mutex.synchronize do + if @store.has_key?(pair) + return @store[pair][1] + else return nil + end + end + end + + def push(pair, data) + @mutex.synchronize do + @store[pair] = Tuple.new(Time.now+Time::Span.new(1,0,0), data) + if(@store.size > @cache_limit) + clean + end + end + end + + def clean + @mutex.synchronize do + curr = Time.now + @store.reject! do |k, v| + return v[0]>curr + end + end + end + + def invalidate(pair) + @mutex.synchronize do + @store.reject! pair + end + end +end + +class KVStore + property database : DB::Database + property st_fetch : DB::PoolPreparedStatement + property st_push : DB::PoolPreparedStatement + property cache : CacheLRU + + def initialize + @database = DB.open(Statics.data_path) + + @database.exec "create table if not exists kv (k TEXT PRIMARY KEY, v TEXT);" + + @st_fetch = DB::PoolPreparedStatement.new @database, "select v from kv where k=?;" + @st_push = DB::PoolPreparedStatement.new @database, "insert into kv(k,v) values(?,?) on conflict(k) do update set v=?;" + @cache = CacheLRU.new + end + + def fetch(key : String) : User | Nil + begin + f = @cache.try(key) + if(f.nil?) + v = @st_fetch.scalar(key) + u = User.from_json v.to_s + @cache.push(key, u) + if(v.nil?) + return nil + else + return u + end + else + return f + end + rescue ex + return nil + end + end + + def fetch!(key : String) : User + fetch(key).not_nil! + end + + def push(key : String, value : String) + @cache.invalidate key + @st_push.exec(key, value, value) + end + + def transaction() + @database.transaction do |tx| + yield self + end + end + + def self.access + @@instance ||= new + end +end \ No newline at end of file diff --git a/src/config.cr b/src/config.cr index 11f013f..ca57b97 100644 --- a/src/config.cr +++ b/src/config.cr @@ -1,9 +1,9 @@ class Statics def self.data_path - if ENV["KEMAL_ENV"] == "test" - return "/tmp/" + if ENV.fetch("KEMAL_ENV", "test") == "test" + return "sqlite3:/tmp/database.db" end - "/opt/app/data/" + "sqlite3:/opt/app/data/database.db" end def self.max_product_query @@ -41,5 +41,9 @@ class Statics limit=Statics.max_product_query end {skip, limit} - end + end + + def self.nb_udp_listeners + 5 + end end \ No newline at end of file