123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442(**************************************************************************)(* *)(* 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. *)(* *)(**************************************************************************)(* TODO:
* Use a better hash fuction than md5 !!!
*)letdebug=falsemoduleTYPES=struct(** Foreign user without password, whose login is equal to [foreign_origin ^ foreign_token] and
whose password is not req. *)typeforeign_info={foreign_origin:string;foreign_token:string;}(** A session that helps to keep connection for the given user and stores useful information
about his communication with the sever. *)type'user_idsession={session_token:string;session_login:string;session_user_id:'user_id;session_last:float;session_foreign:foreign_infooption;}(** Main module that specifies the session parameters for given implementation of API server. *)moduletypeSessionArg=sig(** User identifier, which generally is the same as login *)typeuser_id(** Associated to user information *)typeuser_info(** Web host, that should be used in access control headers, if specified. If web_host isn't specified,
then acces-control header in response will be set to '*' and authentication wwith cookies wouldn't
work.
Note : Cookies would be set by browser only if request's flag 'with_credentials' is set to true.
Last one in turn, requires that "Access-control_allow_origin" header by reponse returns something
different from "*". *)valweb_host:stringoption(** Json encoding for user's id *)valuser_id_encoding:user_idJson_encoding.encoding(** Json encoding for user's information *)valuser_info_encoding:user_infoJson_encoding.encoding(** RPC path where authentication services like {b connect}, {b login} and {b logout}
would be placed. *)valrpc_path:stringlist(* ["v1"] *)(*
Using a cookie (e.g. `Cookie "EZSESSION" `) allows CSRF (Client-Side
Request Forgery), it is better to use a specific header for security
(`CSRF "X-Csrf-Token" `).
*)(** Describes two ways to store a token within a client request :
- Stores as a cookie associated with the given cookie name and its max-age, if provided.
- Stores as a CSRF header with the given name. *)valtoken_kind:[`Cookieofstring*int64option|`CSRFofstring]end(** Authentification information returned by server after successful connection *)type('user_id,'user_info)auth={auth_login:string;auth_user_id:'user_id;auth_token:string;auth_user_info:'user_info;}(** Challenge that should be resolved to be able to connect *)typeauth_needed={challenge_id:string;challenge:string;}(** Connection response, that either describes user information if connection successes either
describes challenge to resolve to be able to connect *)type'authconnect_response=|AuthOkof'auth|AuthNeededofauth_needed(** Logining request, that contains the login and the challenge resolution obtained by hashing
challenge and password provided by the user. *)typelocal_login_message={login_user:string;login_challenge_id:string;login_challenge_reply:string;}(** Logining request, that could be asked either by user with the password provided either by a
foreign user without a password. *)typelogin_message=|Localoflocal_login_message|Foreignofforeign_info(** Possible logining outcomes. *)type('user_id,'user_info)login_response=|LoginOkof('user_id,'user_info)auth|LoginWaitof'user_id(** Errors that could be raised while logining. *)typelogin_error=[`Bad_user_or_password|`User_not_registered|`Unverified_user|`Challenge_not_found_or_expiredofstring|`Invalid_session_loginofstring](** Errors that could be raised while disconnecting. *)typelogout_error=[`Invalid_session_logoutofstring](** Errors that could be raised while connecting. *)typeconnect_error=[`Session_expired|`Invalid_session_connectofstring]endopenTYPES(** Hash module, that hashing algorithms. *)moduleHash=structincludeEzHash(** Hashed version of the password that is computed by the hash function applied on
[login ^ password] *)letpassword~login~password=lets=hash(login^password)inifdebugthenEzDebug.printf"EzSession.Hash.password:\n %S %S => %S"loginpasswords;s(** Hashed version of the challenge that is computed by the hash function applied on
[challenge ^ pwhash] *)letchallenge~challenge~pwhash=lets=hash(challenge^pwhash)inifdebugthenEzDebug.printf"EzSession.Hash.challenge:\n %S %S => %S"challengepwhashs;send(** Output signature for Make functor *)moduletypeM=sigtypeuser_idtypeuser_infotypenonrecauth=(user_id,user_info)authtypetoken_security=[EzAPI.Security.cookie|EzAPI.Security.header|EzAPI.Security.query]valsection_session:EzAPI.Doc.sectionvalparam_token:EzAPI.Param.tvalsecurity:token_securitylistvalconnect:(authconnect_response,connect_error,token_security)EzAPI.service0vallogin:(login_message,(user_id,user_info)login_response,login_error,EzAPI.Security.none)EzAPI.post_service0vallogout:(auth_needed,logout_error,token_security)EzAPI.service0end(** Main functor that produces definition for authentication services and encodings for types used
by service's input, output and errors. *)moduleMake(S:SessionArg)=structtypenonrecauth=(S.user_id,S.user_info)auth(** Encodings for data types used in server's requests/responses and for error cases that
could be raised by one of them. *)moduleEncoding=structopenJson_encodingletauth_needed=def~title:"needed""needed_authentication"@@conv(fun{challenge_id;challenge}->(challenge_id,challenge))(fun(challenge_id,challenge)->{challenge_id;challenge})@@obj2(req"challenge_id"string)(req"challenge"string)letauth_ok=def~title:"success""success_authentication"@@conv(fun{auth_login;auth_user_id;auth_token;auth_user_info}->(auth_login,auth_user_id,auth_token,auth_user_info))(fun(auth_login,auth_user_id,auth_token,auth_user_info)->{auth_login;auth_user_id;auth_token;auth_user_info})@@obj4(req"login"EzEncoding.encoded_string)(req"user_id"S.user_id_encoding)(req"token"string)(req"user_info"S.user_info_encoding)letconnect_response=union[caseauth_ok(functionAuthOkx->Somex|_->None)(funx->AuthOkx);caseauth_needed(functionAuthNeededx->Somex|_->None)(funx->AuthNeededx)]letforeign_message=def~title:"foreign login""foreign_login_message"@@conv(fun{foreign_origin;foreign_token}->(foreign_origin,foreign_token))(fun(foreign_origin,foreign_token)->{foreign_origin;foreign_token})@@obj2(req"auth_origin"string)(req"token"string)letlocal_message=def~title:"local login""local_login_message"@@conv(fun{login_user;login_challenge_id;login_challenge_reply}->(login_user,login_challenge_id,login_challenge_reply))(fun(login_user,login_challenge_id,login_challenge_reply)->{login_user;login_challenge_id;login_challenge_reply})(obj3(req"user"EzEncoding.encoded_string)(req"challenge_id"string)(req"challenge_reply"EzEncoding.encoded_string))letlogin_message=union[caselocal_message(functionLocall->Somel|_->None)(funl->Locall);caseforeign_message(functionForeignf->Somef|_->None)(funf->Foreignf)]letlogin_response=union[caseauth_ok(functionLoginOkx->Somex|_->None)(funx->LoginOkx);case(def~title:"pending""login_validation_pending"@@obj1(req"user_id"S.user_id_encoding))(functionLoginWaitx->Somex|_->None)(funx->LoginWaitx)]letsession_expired_case=EzAPI.Err.Case{code=440;name="SessionExpired";encoding=(obj1(req"error"(constant"SessionExpired")));select=(function`Session_expired->Some()|_->None);deselect=(fun()->`Session_expired);}letbad_user_case=EzAPI.Err.Case{code=401;name="BadUserOrPassword";encoding=(obj1(req"error"(constant"BadUserOrPassword")));select=(function`Bad_user_or_password->Some()|_->None);deselect=(fun()->`Bad_user_or_password);}letuser_not_registered_case=EzAPI.Err.Case{code=400;name="UserNotRegistered";encoding=(obj1(req"error"(constant"UserNotRegistered")));select=(function`User_not_registered->Some()|_->None);deselect=(fun()->`User_not_registered);}letunverified_user_case=EzAPI.Err.Case{code=400;name="UnverifiedUser";encoding=(obj1(req"error"(constant"unverified")));select=(function`Unverified_user->Some()|_->None);deselect=(fun()->`Unverified_user);}letchallenge_not_found_case=EzAPI.Err.Case{code=401;name="ChallengeNotFoundOrExpired";encoding=(obj2(req"error"(constant"ChallengeNotFoundOrExpired"))(req"challenge_id"string));select=(function`Challenge_not_found_or_expireds->Some((),s)|_->None);deselect=(fun((),s)->`Challenge_not_found_or_expireds);}letinvalid_session_login_case=EzAPI.Err.Case{code=400;name="InvalidSession";encoding=(obj2(req"error"(constant"InvalidSession"))(req"reason"string));select=(function`Invalid_session_logins->Some((),s)|_->None);deselect=(fun((),s)->`Invalid_session_logins);}letinvalid_session_logout_case=EzAPI.Err.Case{code=400;name="InvalidSession";encoding=(obj2(req"error"(constant"InvalidSession"))(req"reason"string));select=(function`Invalid_session_logouts->Some((),s));deselect=(fun((),s)->`Invalid_session_logouts);}letinvalid_session_connect_case=EzAPI.Err.Case{code=400;name="InvalidSession";encoding=(obj2(req"error"(constant"InvalidSession"))(req"reason"string));select=(function`Invalid_session_connects->Some((),s)|_->None);deselect=(fun((),s)->`Invalid_session_connects);}end(** Definition for services and their security's configuration. *)moduleService=structtypeuser_id=S.user_idtypeuser_info=S.user_infotypenonrecauth=auth(** Documentation section for openapi. *)letsection_session=EzAPI.Doc.section"Session Requests"(** Parameter with name {i token} that stores an authentication token string *)letparam_token=EzAPI.Param.string~name:"token"~descr:"An authentication token""token"(** Type that represents security by authentication token and the way that request uses
to store it. *)typetoken_security=[EzAPI.Security.cookie|EzAPI.Security.header|EzAPI.Security.query](** Security that requires [param_token] parameter in query. *)letparam_security=EzAPI.(`Query{Security.ref_name="Token parameter";name=param_token})(** Security that checks [S.token_kind]:
If it is a CSRF token, then requires a CSRF header.
Otherwise requires token to be found in the cookies.
*)letheader_cookie_security=matchS.token_kindwith|`CSRFname->EzAPI.(`Header{Security.ref_name=name^" Header";name})|`Cookie(name,max_age)->EzAPI.(`Cookie({Security.ref_name=name^" Cookie";name},max_age))(** Security that combines [param_security] and [header_cookie_security]
in the corresponding order. Represents the security configuration for
[connect] and [logout] requests.
*)letsecurity:token_securitylist=[param_security;(* Parameter fisrt *)header_cookie_security;(* Header CSRF or Cookie *)](** Defines path to authentication services *)letrpc_root=List.fold_left(funpaths->EzAPI.Path.(path//s))EzAPI.Path.rootS.rpc_pathletaccess_control=["access-control-allow-credentials","true";"access-control-allow-origin",matchS.web_hostwithNone->"*"|Someorigin->origin](** Connection service that requires authentication token. For more details, see corresponding
[EzSessionServer.Make.connect] handler and default client request implementation
[EzSessionClient.Make.connect]. *)letconnect:(authconnect_response,connect_error,token_security)EzAPI.service0=EzAPI.service~section:section_session~name:"connect"~output:Encoding.connect_response~errors:[Encoding.session_expired_case;Encoding.invalid_session_connect_case]~security~access_controlEzAPI.Path.(rpc_root//"connect")(** Logining service. For more details, see corresponding [EzSessionServer.Make.login] handler
and default client request implementation [EzSessionClient.Make.login]. *)letlogin:(login_message,(S.user_id,S.user_info)login_response,login_error,EzAPI.Security.none)EzAPI.post_service0=EzAPI.post_service~section:section_session~name:"login"~input:Encoding.login_message~output:Encoding.login_response~errors:[Encoding.bad_user_case;Encoding.user_not_registered_case;Encoding.unverified_user_case;Encoding.challenge_not_found_case;Encoding.invalid_session_login_case]~access_controlEzAPI.Path.(rpc_root//"login")(** Disconnection service that requires authentication token. For more details, see corresponding
[EzSessionServer.Make.logout] handler and default client request implementation
[EzSessionClient.Make.logout]. *)letlogout:(auth_needed,logout_error,token_security)EzAPI.service0=EzAPI.service~section:section_session~name:"logout"~meth:`PUT~output:Encoding.auth_needed~errors:[Encoding.invalid_session_logout_case]~security~access_controlEzAPI.Path.(rpc_root//"logout")endend