123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455(**************************************************************************)(* *)(* Copyright 2018-2023 OCamlPro *)(* *)(* All rights reserved. This file is distributed under the terms of the *)(* GNU Lesser General Public License version 2.1, with the special *)(* exception on linking described in the file LICENSE. *)(* *)(**************************************************************************)openEzAPIServerUtilsopenEzSession.TYPESopenLwt.Infix(* WARNINGS:
* A user might try to fill the table of cookies with useless entries
* Beware of CSS: user logins should be checked against injection of
code (HTML, PGSQL)
* Beware of cookie prediction, use more entropy for randomness
*)(* maximal number of stored challenges at any time *)letmax_challenges=10_000(* size of challenge_id, challenge and cookie *)letchallenge_size=30(* initial size of all hashtbls *)letinitial_hashtbl_size=100exceptionUserAlreadyDefinedexceptionNoPasswordProvidedletrandomChars="abcdefghijklmnopqrstuvxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"letrandomCharsLen=String.lengthrandomChars(* challenges should be printable to enforce that they can be directly
written in URLs*)letrandom_challenge()=String.initchallenge_size(fun_->randomChars.[Random.intrandomCharsLen])moduletypeSessionStore=sigtypeuser_idvalcreate_session:?foreign:foreign_info->login:string->req:Req.t->user_id->user_idsessionLwt.tvalget_session:?req:Req.t->string->user_idsessionoptionLwt.tvalremove_session:user_id->token:string->unitLwt.tendmoduletypeArg=sigmoduleSessionArg:EzSession.TYPES.SessionArgmoduleSessionStore:SessionStorewithtypeuser_id=SessionArg.user_idvalfind_user:login:string->(stringoption*SessionArg.user_id*SessionArg.user_info)optionLwt.tvalcheck_foreign:origin:string->token:string->(string,int*stringoption)resultLwt.tvalregister_foreign:origin:string->token:string->(SessionArg.user_id*SessionArg.user_infooption,int*stringoption)resultLwt.tend(** Dummy implementation for [Arg.check_foreign] *)letdefault_check_foreign~origin~token=ignore(origin,token);Lwt.return(Error(400,Some"Foreign authentication not implemented"))(** Dummy implementation for [Arg.register_foreign] *)letdefault_register_foreign~origin~token=ignore(origin,token);Lwt.return(Error(400,Some"Foreign registration not implemented"))moduleMake(S:Arg):sigmoduleService:EzSession.Mwithtypeuser_id=S.SessionArg.user_idandtypeuser_info=S.SessionArg.user_infovalregister_handlers:Directory.t->Directory.tvalget_request_session:Req.t->S.SessionArg.user_idsessionoptionLwt.tend=structletfind_user=S.find_userletcheck_foreign=S.check_foreignletregister_foreign=S.register_foreignopenS.SessionStoremoduleS=S.SessionArgmoduleM=EzSession.Make(S)includeM(** Searches in the given request for the parameter indicated in security configuration.
Returns concatenated with ',' strting containing all values associated to the parameter,
if exists. *)letcookie_of_paramreq(`Query{EzAPI.Security.name=param;_})=Req.find_paramparamreq(** Searches in the given request for the cookie with the name as indicated in security
configuration. Cookies aree implemented only for given Cohttp request implementation. *)letcookie_of_cookiereq(`Cookie({EzAPI.Security.name;_},_))=trySome(StringMap.findname(EzCookieServer.getreq))withNot_found->None(** Searches in the given request for the header with the name indicated in security
configuration. Returns the first occurence of the value for the given header. *)letcookie_of_headerreq(`Header{EzAPI.Security.name;_})=letname=String.lowercase_asciinameinmatchStringMap.findnamereq.Req.req_headerswith|exceptionNot_found->None|[]->None|cookie::_->Somecookie(** Extracts token from then given request by applying one from the defined security
configurations. If token was succesfully retreived, then it looks up and returns
associated to it session. *)letget_request_sessionsecurityreq=List.map(function|`Query_ass->cookie_of_paramreqs|`Cookie_ass->cookie_of_cookiereqs|`Header_ass->cookie_of_headerreqs)security|>Lwt_list.fold_left_s(function|Somes->fun_->Lwt.return_somes|None->function|None->Lwt.return_none|Somecookie->get_session~reqcookie)NonemoduleHandler=struct(** Hash map of challenges' id associated with challenge itself and with a time it
was created (client's request time). *)letchallenges=Hashtbl.createinitial_hashtbl_size(** Queue of challenge ids, that allows to remove the oldest one when maximal size
of challenge is achieved. *)letchallenge_queue=Queue.create()(** Create new [auth_needed] that contains random challenge with its random id. If
maximal size of id challenges is reached, the oldest id is reused to store new
challenge *)letrecnew_challengereq=letchallenge_id=random_challenge()inifHashtbl.memchallengeschallenge_idthennew_challengereqelseletchallenge=random_challenge()inifQueue.lengthchallenge_queue>max_challengesthenbeginletchallenge_id=Queue.takechallenge_queueinHashtbl.removechallengeschallenge_idend;Hashtbl.addchallengeschallenge_id(challenge,req.Req.req_time);Queue.addchallenge_idchallenge_queue;{challenge_id;challenge}(** Returns authentication header that sould be then added inside the server response.
If [S.token_kind] is a cookie, then create corresponding {i Set-Cookie} header with
[token] as a cookie value, if it is specified (else, clears cookie). Otherwise creates
header {i Access-control-allow-headers} and mention CSRF header name that should be present
for every client's request for authentication purpose. *)letadd_auth_header?(clear_cookie=false)?token()=matchS.token_kindwith|`Cookie(name,max_age)->beginmatchtoken,clear_cookiewith|(None,false)->[]|(_,true)->[EzCookieServer.clear~name()]|(Somevalue,_)->[EzCookieServer.set?expiration:max_age~name~value()]end|`CSRFheader->["access-control-allow-headers",header]letrequest_auth_base?(clear_cookie=false)reqf=letheaders=add_auth_header~clear_cookie()inletres,code=f@@new_challengereqinreturn?code~headersres(** Creates authentification response that returns challenge to resolve. Adds
authentification header with [add_auth_header]. Clears the cookie, if presents. *)letrequest_authreq=request_auth_base~clear_cookie:truereq(funauth_needed->Ok(AuthNeededauth_needed),Some200)(** Creates response that contains error with specified code. Adds authentification
header with [add_auth_header] *)letrequest_error?(clear_cookie=false)~codemsg=letheaders=add_auth_header~clear_cookie()inreturn~code~headers(Errormsg)letreturn_auth_basereq?token?foreign~loginuser_iduser_infof=beginmatchtokenwith|Sometoken->Lwt.returntoken|None->create_session?foreign~login~requser_id>>=functions->Lwt.returns.session_tokenend>>=functiontoken->letheaders=add_auth_header~token()inletauth={auth_login=login;auth_user_id=user_id;auth_token=token;auth_user_info=user_info}inreturn~headers(fauth)(** Creates login response, that returns authentification information. Return [LoginWait]
if user info isn't provided. If token isn't provided then the new session is created and
new session's token is used. *)letreturn_authreq?token?foreign~loginuser_iduser_info=matchuser_infowith|Someuser_info->return_auth_basereq?token?foreign~loginuser_iduser_info(funauth->Ok(LoginOkauth))|None->return(Ok(LoginWaituser_id))(** Connection service handler. It performs next actions:
- Looks up for the session that is associated to the token extracted from request (returned by [get_request_session]). If session or token don't exist then returns challenge to resolve with
[request_auth].
- If session was found, then it extracts user's login related to the session and search for the corresponding user information. If user doesn't exists, it means that session is expired and it responds with an error.
- If user exists then checks if it contains password. If it does, then returns response containing authentification information and authentification headers.
- If it doesn't checks if foreign user with given login exists and returns its information.
- Otherwise returns an error {!Invalid_session_connect}. *)letconnectreqsecurity()=get_request_sessionsecurityreq>>=function|None->request_authreq|Some{session_token=token;session_login=login;session_foreign=foreign;_}->find_user~login>>=function|None->request_error~clear_cookie:true~code:440`Session_expired|Some(pwhash,user_id,user_info)->matchpwhash,foreignwith|Some_pwhash,None->return_auth_basereq~token~loginuser_iduser_info(funa->Ok(AuthOka))|None,Some{foreign_origin=origin;foreign_token=token}->check_foreign~origin~token>>=(function|Error_e->request_error~clear_cookie:true~code:440`Session_expired|Okforeign_id->iflogin=foreign_idthenreturn_auth_basereq~token~login?foreignuser_iduser_info(funa->Ok(AuthOka))elserequest_error~code:400(`Invalid_session_connect"wrong user"))|_->request_error~code:400(`Invalid_session_connect"wrong type of authentication")(** Login service handler. It performs next actions for local users (have password):
- Search for the user with the provided login.
- Verify password by comparing challenge reply send by client with expected one computed by server.
- Discard used challenge.
- Create session with given user and storee it.
- Returns authentification information (if login sucessed).
And for foreign (without password) users:
- Checks if foreign user exists and get its login.
- Searching for user information with the given login.
- If user exists, create session and storee it.
- Otherwise, register foreign user and returns authentification information. *)letloginreq_=function|Local{login_user;login_challenge_id;login_challenge_reply}->beginfind_user~login:login_user>>=function|Some(Somepwhash,user_id,user_info)->beginmatchHashtbl.findchallengeslogin_challenge_idwith|exceptionNot_found->debug~v:1"/login: could not find challenge\n%!";request_error~code:401(`Challenge_not_found_or_expiredlogin_challenge_id)|(challenge,_t0)->letexpected_reply=EzSession.Hash.challenge~challenge~pwhashinifexpected_reply<>login_challenge_replythenbegindebug~v:1"/login: challenge failed";request_error~code:401`Bad_user_or_passwordendelsebeginHashtbl.removechallengeslogin_challenge_id;return_authreq~login:login_useruser_id(Someuser_info)endend|_->debug~v:1"/login: could not find user %S"login_user;request_error~code:401`Bad_user_or_passwordend|Foreign{foreign_origin;foreign_token}->check_foreign~origin:foreign_origin~token:foreign_token>>=function|Error_->request_error~code:400(`Invalid_session_login"foreign authentication fail")|Okforeign_login->find_user~login:foreign_login>>=function|Some(_,user_id,user_info)->return_authreq~login:foreign_login~foreign:{foreign_origin;foreign_token}user_id(Someuser_info)|None->register_foreign~origin:foreign_origin~token:foreign_token>>=function|Ok(user_id,user_info)->return_authreq~login:foreign_login~foreign:{foreign_origin;foreign_token}user_iduser_info|Error_->debug~v:1"/login: could not register foreign user";request_error~code:400`User_not_registered(** Connection service handler that at the end returns new challenge to make possible
further connections. It checks for authentification token within request and if it
exists then remove current session associtated to the token. Otherwise returns an error. *)letlogoutreqsecurity()=get_request_sessionsecurityreq>>=function|None->return~code:400(Error(`Invalid_session_logout"session doesn't exist"))|Some{session_user_id;session_token=token;_}->remove_sessionsession_user_id~token>>=fun()->request_auth_base~clear_cookie:truereq(funauth_needed->Okauth_needed,None)endletregister_handlersdir=dir|>registerService.connectHandler.connect|>registerService.loginHandler.login|>registerService.logoutHandler.logoutletget_request_sessionreq=get_request_sessionService.securityreqendmoduleSessionStoreInMemory:SessionStorewithtypeuser_id=string=struct(*
TODO: When crowded, we should:
* limit the number of sessions by users
* get rid of oldest sessions in general
*)typeuser_id=string(** Hash map that stores sessions by token *)let(session_by_token:(string,user_idsession)Hashtbl.t)=Hashtbl.createinitial_hashtbl_sizeletreccreate_session?foreign~login~requser_id=lettoken=random_challenge()inifHashtbl.memsession_by_tokentokenthencreate_session~login~requser_idelsebeginlets={session_login=login;session_user_id=user_id;session_token=token;session_foreign=foreign;session_last=req.Req.req_time;}inHashtbl.addsession_by_tokentokens;Lwt.returnsendletget_session?reqtoken=matchHashtbl.findsession_by_tokentokenwith|exceptionNot_found->Lwt.returnNone|s->lets=matchreqwithNone->s|Somereq->{swithsession_last=req.Req.req_time}inLwt.return(Somes)letremove_sessionuser_id~token=get_sessiontoken>>=function|None->Lwt.return()|Somes->ifs.session_user_id=user_idthenHashtbl.removesession_by_tokentoken;Lwt.return()endmoduleUserStoreInMemory(S:EzSession.TYPES.SessionArgwithtypeuser_id=string):sigvalcreate_user:?pwhash:string->?password:string->?kind:string->login:string->S.user_info->unitvalremove_user:login:string->unitvalfind_user:login:string->(stringoption*S.user_id*S.user_info)optionLwt.tvalcheck_foreign:origin:string->token:string->(string,int*stringoption)resultLwt.tvalregister_foreign:origin:string->token:string->(S.user_id*S.user_infooption,int*stringoption)resultLwt.tmoduleSessionArg:EzSession.TYPES.SessionArgwithtypeuser_info=S.user_infoandtypeuser_id=S.user_idmoduleSessionStore:SessionStorewithtypeuser_id=S.user_idend=structmoduleSessionArg=SmoduleSessionStore=(SessionStoreInMemory:SessionStorewithtypeuser_id=S.user_id)(** User information *)typeuser={login:string;user_id:S.user_id;pwhash:string;(* hash of password *)user_info:S.user_info;kind:stringoption;}(** Hash map of users by their login *)let(users:(string,user)Hashtbl.t)=Hashtbl.createinitial_hashtbl_sizeletcreate_user?pwhash?password?kind~loginuser_info=debug~v:1"create_user %S ?"login;ifHashtbl.memusersloginthenraiseUserAlreadyDefined;matchkindwith|Some_->debug~v:1"create_user %S ok"login;Hashtbl.adduserslogin{login;pwhash="";user_id=login;user_info;kind}|None->letpwhash=matchpwhashwith|Somepwhash->pwhash|None->matchpasswordwith|None->raiseNoPasswordProvided|Somepassword->EzSession.Hash.password~login~passwordindebug~v:1"create_user %S ok"login;Hashtbl.adduserslogin{login;pwhash;user_id=login;user_info;kind}letfind_user~login=debug~v:1"find_user %S ?"login;matchHashtbl.findusersloginwith|exceptionNot_found->Lwt.returnNone|u->debug~v:1"find_user %S ok"login;letpwhash=matchu.kindwithNone->Someu.pwhash|Some_k->NoneinLwt.return(Some(pwhash,u.user_id,u.user_info))letcheck_foreign~origin~token=debug~v:1"check_foreign %S ?"(origin^"-"^token);matchHashtbl.findusers(origin^"-"^token)with|exceptionNot_found->Lwt.return(Error(500,Some"User not found"))|u->debug~v:1"check_foreign %S ok"(origin^"-"^token);Lwt.return(Oku.login)letregister_foreign=default_register_foreignletremove_user~login=Hashtbl.removeusersloginend