Browse Source

version 0.1.0

master
Ludovic 'Archivist' Lagouardette 5 years ago
parent
commit
f018614bc4
11 changed files with 437 additions and 95 deletions
  1. +4
    -2
      Dockerfile
  2. +2
    -3
      README.md
  3. +76
    -0
      auth_bench_udp.cr
  4. +62
    -0
      auth_bench_ws.cr
  5. +15
    -3
      shard.lock
  6. +4
    -2
      shard.yml
  7. +6
    -5
      spec/authenticate_spec.cr
  8. +1
    -1
      spec/spec_helper.cr
  9. +160
    -75
      src/authenticate/http/user.cr
  10. +99
    -0
      src/authenticate/sqlite_kv.cr
  11. +8
    -4
      src/config.cr

+ 4
- 2
Dockerfile View File

@ -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

+ 2
- 3
README.md View File

@ -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 (<https://github.com/your-github-user/sales_backend/fork>)
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`)

+ 76
- 0
auth_bench_udp.cr View File

@ -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 scurr<endt+Time::Span.new(0,0,4)
scurr = Time.now
Fiber.yield
end
puts "avg: "+(total/cnt_r.get).to_s
puts "min: "+min.to_s
puts "max: "+max.to_s
puts "cnt_s: "+cnt_s.get.to_s
puts "cnt_r: "+cnt_r.get.to_s
puts "rps: "+(cnt_r.get/duration.seconds).to_s

+ 62
- 0
auth_bench_ws.cr View File

@ -0,0 +1,62 @@
require "http/web_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 = HTTP::WebSocket.new URI.parse("http://127.0.0.1:3000/socket/user/authenticate")
soc.on_message do |message|
begin
mess = JSON.parse message
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
end
spawn do
soc.run
end
curr=Time.now
while endt>curr
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 scurr<endt+Time::Span.new(0,0,4)
scurr = Time.now
Fiber.yield
end
puts "avg: "+(total/cnt_r.get).to_s
puts "min: "+min.to_s
puts "max: "+max.to_s
puts "cnt_s: "+cnt_s.get.to_s
puts "cnt_r: "+cnt_r.get.to_s
puts "rps: "+(cnt_r.get/duration.seconds).to_s

+ 15
- 3
shard.lock View File

@ -1,8 +1,16 @@
version: 1.0
shards:
db:
github: crystal-lang/crystal-db
version: 0.5.1
exception_page:
github: crystal-loot/exception_page
version: 0.1.2
kemal:
github: kemalcr/kemal
version: 0.23.0
version: 0.25.0
kilt:
github: jeromegn/kilt
@ -10,9 +18,13 @@ shards:
radix:
github: luislavena/radix
version: 0.3.8
version: 0.3.9
spec-kemal:
github: kemalcr/spec-kemal
version: 0.4.0
version: 0.5.0
sqlite3:
github: crystal-lang/crystal-sqlite3
version: 0.10.0

+ 4
- 2
shard.yml View File

@ -8,13 +8,15 @@ targets:
authenticate:
main: src/authenticate.cr
crystal: 0.25.0
crystal: 0.27.0
dependencies:
kemal:
github: kemalcr/kemal
version: 0.23.0
version: 0.25.0
spec-kemal:
github: kemalcr/spec-kemal
sqlite3:
github: crystal-lang/crystal-sqlite3
license: MIT

spec/sales_backend_spec.cr → spec/authenticate_spec.cr View File

@ -1,6 +1,6 @@
require "./spec_helper"
describe SalesBackend do
describe Authenticate do
it "can render status" do
get "/"
String.from_json(Global.response.not_nil!.body).should eq "OK"
@ -52,7 +52,7 @@ describe SalesBackend do
String.from_json(Global.response.not_nil!.body).should eq "OK"
post "/user", nil, usr.to_json
Global.response.not_nil!.status_code.should eq(500)
Global.response.not_nil!.status_code.should eq(401)
post "/login", nil, usr.to_json
Global.response.not_nil!.status_code.should eq(200)
@ -61,7 +61,7 @@ describe SalesBackend do
headers = HTTP::Headers.new
headers["user"] = usr.email.to_s
headers["api_token"] = uuid.to_s
get "/user/", headers
get "/user/", headers
Global.response.not_nil!.status_code.should eq(200)
get "/user/address", headers
Global.response.not_nil!.status_code.should eq(200)
@ -93,8 +93,9 @@ describe SalesBackend do
Global.response.not_nil!.status_code.should eq(200)
usr.tokens = Array(UUID).new
usr.tokens.not_nil!.push uuid
post "/logout", nil, usr.to_json
usr.tokens.not_nil!.push uuid
get "/logout", headers
Global.response.not_nil!.status_code.should eq(200)
get "/user/", headers

+ 1
- 1
spec/spec_helper.cr View File

@ -1,2 +1,2 @@
require "spec-kemal"
require "../src/sales_backend"
require "../src/authenticate"

+ 160
- 75
src/authenticate/http/user.cr View File

@ -6,17 +6,27 @@ require "exception"
require "crypto/bcrypt/password"
require "uuid"
require "uuid/json"
require "socket/udp_socket"
require "../../config"
def fread(file) : String
slc = Bytes.new file.size
count = file.read slc
String.new slc[0, count]
end
def authenticate(user : String, token : UUID) : (User | Nil)
user_file = User.from_json File.read(Statics.data_path+"user/"+user)
if nil == user_file.tokens.not_nil!.find{ |tok| token == tok}
nil
else
user_file.password_hash = ""
user_file
end
user_file : User | Nil = nil
user_file = KVStore.access.fetch!("user/"+user)
if user_file.nil?
return nil
end
if nil == user_file.not_nil!.tokens.not_nil!.find{ |tok| token == tok}
nil
else
user_file.not_nil!.password_hash = ""
user_file
end
end
def authenticate!(user : String, token : UUID) : User
@ -31,39 +41,81 @@ def authenticate_admin!(user : String, token : UUID) : User
raise "Administrator only"
end
ws "/socket/user/authenticate" do |socket|
socket.on_message do |message|
json = JSON.parse(message)
if(authenticate(json["user"].to_s, UUID.new(json["api_token"].to_s)).nil?)
json.as_h.["valid"]=JSON::Any.new false
else
json.as_h.["valid"]=JSON::Any.new true
end
socket.send json.to_json
end
end
udp_listener = UDPSocket.new
udp_listener.bind "0.0.0.0", 3000
udp_mutex = Mutex.new
(1..Statics.nb_udp_listeners).each do
spawn do
while true
message : String | Nil = nil
address : Socket::IPAddress | Nil = nil
udp_mutex.synchronize do
message, address = udp_listener.receive
end
json = JSON.parse(message.not_nil!)
if(authenticate(json["user"].to_s, UUID.new(json["api_token"].to_s)).nil?)
json.as_h.["valid"]=JSON::Any.new false
else
json.as_h.["valid"]=JSON::Any.new true
end
udp_mutex.synchronize do
udp_listener.send json.to_json, address.not_nil!
end
end
end
end
post "/login" do |context|
user : User
user_file : User
begin
user = User.from_json context.request.body.not_nil!
user_file = User.from_json File.read(Statics.data_path+"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
token = UUID.random()
if user_file.tokens.nil?
user_file.tokens = Array(UUID).new
user_file.tokens.not_nil!<<token
else
user_file.tokens.not_nil!<<token
end
if user_file.tokens.not_nil!.size>5
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 o">= 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!<<token
if user_file.tokens.not_nil!.size>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

+ 99
- 0
src/authenticate/sqlite_kv.cr View File

@ -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

+ 8
- 4
src/config.cr View File

@ -1,9 +1,9 @@
class Statics
def self.data_path
if ENV["KEMAL_ENV"o">] == "test"
return "/tmp/"
if ENV.fetch("KEMAL_ENV"p">, "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

Loading…
Cancel
Save