View source with formatted comments or as raw
    1/*  Part of SWI-Prolog
    2
    3    Author:        Jan Wielemaker
    4    E-mail:        J.Wielemaker@vu.nl
    5    WWW:           http://www.swi-prolog.org
    6    Copyright (c)  2007-2015, University of Amsterdam
    7                              VU University Amsterdam
    8    All rights reserved.
    9
   10    Redistribution and use in source and binary forms, with or without
   11    modification, are permitted provided that the following conditions
   12    are met:
   13
   14    1. Redistributions of source code must retain the above copyright
   15       notice, this list of conditions and the following disclaimer.
   16
   17    2. Redistributions in binary form must reproduce the above copyright
   18       notice, this list of conditions and the following disclaimer in
   19       the documentation and/or other materials provided with the
   20       distribution.
   21
   22    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
   23    "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
   24    LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
   25    FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
   26    COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
   27    INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
   28    BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
   29    LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
   30    CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
   31    LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
   32    ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
   33    POSSIBILITY OF SUCH DAMAGE.
   34*/
   35
   36:- module(authenticate,
   37          [ http_authenticate/3,        % +Check, +Header, -User
   38            http_authorization_data/2,  % +AuthorizationText, -Data
   39            http_current_user/3,        % +File, ?User, ?Fields
   40
   41            http_read_passwd_file/2,    % +File, -Data
   42            http_write_passwd_file/2    % +File, +Data
   43          ]).   44:- use_module(library(base64)).   45:- use_module(library(dcg/basics)).   46:- use_module(library(readutil)).   47:- use_module(library(lists)).   48:- use_module(library(crypt)).   49:- use_module(library(debug)).   50:- use_module(library(error)).   51:- use_module(library(apply)).   52
   53/**     <module> Authenticate HTTP connections using 401 headers
   54
   55This module provides the basics  to   validate  an  HTTP =Authorization=
   56header. User and password  information  are   read  from  a  Unix/Apache
   57compatible password file.
   58
   59This  library  provides,  in  addition    to  the  HTTP  authentication,
   60predicates to read and write password files.
   61*/
   62
   63%!  http_authenticate(+Type, +Request, -Fields)
   64%
   65%   True if Request contains the   information to continue according
   66%   to Type. Type identifies the required authentication technique:
   67%
   68%           * basic(+PasswordFile)
   69%           Use HTTP =Basic= authetication and verify the password
   70%           from PasswordFile. PasswordFile is a file holding
   71%           usernames and passwords in a format compatible to
   72%           Unix and Apache. Each line is record with =|:|=
   73%           separated fields. The first field is the username and
   74%           the second the password _hash_.  Password hashes are
   75%           validated using crypt/2.
   76%
   77%   Successful authorization is  cached  for   60  seconds  to avoid
   78%   overhead of decoding and lookup of the user and password data.
   79%
   80%   http_authenticate/3 just validates the  header. If authorization
   81%   is not provided the browser must   be challenged, in response to
   82%   which it normally opens a   user-password dialogue. Example code
   83%   realising this is below. The exception   causes the HTTP wrapper
   84%   code to generate an HTTP 401 reply.
   85%
   86%   ==
   87%   (   http_authenticate(basic(passwd), Request, Fields)
   88%   ->  true
   89%   ;   throw(http_reply(authorise(basic, Realm)))
   90%   ).
   91%   ==
   92%
   93%   @param  Fields is a list of fields from the password-file entry.
   94%           The first element is the user.  The hash is skipped.
   95%   @tbd    Should we also cache failures to reduce the risc of
   96%           DoS attacks?
   97
   98http_authenticate(basic(File), Request, [User|Fields]) :-
   99    memberchk(authorization(Text), Request),
  100    debug(http_authenticate, 'Authorization: ~w', [Text]),
  101    (   cached_authenticated(Text, File, User, Fields)
  102    ->  true
  103    ;   http_authorization_data(Text, basic(User, Password)),
  104        debug(http_authenticate,
  105              'User: ~w, Password: ~s', [User, Password]),
  106        validate(File, User, Password, Fields),
  107        get_time(Now),
  108        assert(authenticated(Text, File, User, Now, Fields)),
  109        debug(http_authenticate, 'Authenticated ~w~n', [User])
  110    ).
  111
  112%!  http_authorization_data(+AuthorizeText, ?Data) is semidet.
  113%
  114%   Decode the HTTP =Authorization= header.  Data is a term
  115%
  116%       Method(User, Password)
  117%
  118%   where Method is the (downcased)  authorization method (typically
  119%   =basic=), User is an atom holding the  user name and Password is
  120%   a list of codes holding the password
  121
  122http_authorization_data(Text, Data) :-
  123    (   nonvar(Data)
  124    ->  functor(Data, Method, 2)    % make authorization//2 fail early
  125    ;   true
  126    ),
  127    atom_codes(Text, Codes),
  128    phrase(authorization(Method, Cookie), Codes),
  129    phrase(base64(UserPwd), Cookie),
  130    phrase(ident(UserCodes, Password), UserPwd),
  131    !,
  132    atom_codes(User, UserCodes),
  133    Data =.. [Method, User, Password].
  134
  135authorization(Method, Cookie) -->
  136    nonblanks(MethodChars),
  137    { atom_codes(Method0, MethodChars),
  138      downcase_atom(Method0, Method)
  139    },
  140    blanks,
  141    nonblanks(Cookie),
  142    blanks.
  143
  144ident(User, Password) -->
  145    string(User),
  146    ":",
  147    string(Password).
  148
  149%!  cached_authenticated(+Authorization, +File, -User, -RestFields)
  150%
  151%   Validate using the cache. If the entry   is not in the cache, we
  152%   also remove all outdated entries from the cache.
  153
  154:- dynamic
  155    authenticated/5.        % Authorization, File, User, Time, RestFields
  156
  157cached_authenticated(Authorization, File, User, Fields) :-
  158    authenticated(Authorization, File, User, Time, Fields),
  159    get_time(Now),
  160    Now-Time =< 60,
  161    !.              % 60-second timeout
  162cached_authenticated(_, _, _, _) :-
  163    get_time(Now),
  164    (   clause(authenticated(_, _, _, Time, _), true, Ref),
  165        Now-Time > 60,
  166        erase(Ref),
  167        fail
  168    ).
  169
  170
  171%!  validate(+File, +User, +Passwd, -Fields)
  172%
  173%   True if User and Passwd combination   appears in File. File uses
  174%   the same format as .htaccess files  from Apache or Unix password
  175%   files. I.e. it consists  of  one   line  per  entry  with fields
  176%   separated by =|:|=. The  first  field   is  the  User field, The
  177%   second contains the Passwd in DES   or MD5 encrypted format. See
  178%   crypt/2 for details.
  179
  180validate(File, User, Password, Fields) :-
  181    update_passwd(File, Path),
  182    passwd(User, Path, Hash, Fields),
  183    crypt(Password, Hash).
  184
  185%!  http_current_user(+File, ?User, ?Fields) is nondet.
  186%
  187%   True when User is present in the htpasswd file File and Fields
  188%   provides the additional fields.
  189%
  190%   @arg    Fields are the fields from the password file File,
  191%           converted using name/2, which means that numeric values
  192%           are passed as numbers and other fields as atoms.  The
  193%           password hash is the first element of Fields and is
  194%           a string.
  195
  196http_current_user(File, User, Fields) :-
  197    update_passwd(File, Path),
  198    passwd(User, Path, Hash, Fields0),
  199    Fields = [Hash|Fields0].
  200
  201%!  update_passwd(+File, -Path) is det.
  202%
  203%   Update passwd/3 to reflect the correct  passwords for File. Path
  204%   is the absolute path for File.
  205
  206:- dynamic
  207    passwd/4,                       % User, File, Encrypted, Fields
  208    last_modified/2.                % File, Stamp
  209
  210update_passwd(File, Path) :-
  211    absolute_file_name(File, Path, [access(read)]),
  212    time_file(Path, Stamp),
  213    (   last_modified(Path, Stamp)
  214    ->  true
  215    ;   with_mutex(http_passwd, reload_passwd_file(Path, Stamp))
  216    ).
  217
  218reload_passwd_file(Path, Stamp) :-
  219    last_modified(Path, Stamp),
  220    !.  % another thread did the work
  221reload_passwd_file(Path, Stamp) :-
  222    http_read_passwd_file(Path, Data),
  223    retractall(last_modified(Path, _)),
  224    retractall(passwd(_, Path, _, _)),
  225    forall(member(passwd(User, Hash, Fields), Data),
  226           assertz(passwd(User, Path, Hash, Fields))),
  227    assert(last_modified(Path, Stamp)).
  228
  229%!  http_read_passwd_file(+Path, -Data) is det.
  230%
  231%   Read a password file. Data is  a   list  of  terms of the format
  232%   below, where User is an atom  identifying   the  user, Hash is a
  233%   string containing the salted password   hash  and Fields contain
  234%   additional fields. The string value of   each field is converted
  235%   using name/2 to either a number or an atom.
  236%
  237%     ==
  238%     passwd(User, Hash, Fields)
  239%     ==
  240
  241http_read_passwd_file(Path, Data) :-
  242    setup_call_cleanup(
  243        open(Path, read, Fd),
  244        ( read_line_to_codes(Fd, Line),
  245          read_passwd_file(Line, Fd, Path, Data)
  246        ),
  247        close(Fd)).
  248
  249read_passwd_file(end_of_file, _, _, []) :- !.
  250read_passwd_file(Line, Fd, Path, Data) :-
  251    (   phrase(password_line(User, Hash, Fields), Line, _)
  252    ->  Data = [passwd(User, Hash, Fields)|Tail]
  253    ;   Tail = Data                 % TBD: warning
  254    ),
  255    read_line_to_codes(Fd, Line2),
  256    read_passwd_file(Line2, Fd, Path, Tail).
  257
  258
  259password_line(User, Hash, Fields) -->
  260    string(UserCodes),
  261    ":",
  262    string(HashCodes),
  263    peek_eof,
  264    !,
  265    fields(Fields),
  266    { atom_codes(User, UserCodes),
  267      string_codes(Hash, HashCodes)
  268    }.
  269
  270fields([Field|Fields]) -->
  271    field(Field),
  272    !,
  273    fields(Fields).
  274fields([]) --> [].
  275
  276field(Value) -->
  277    ":",
  278    !,
  279    string(Codes),
  280    peek_eof,
  281    !,
  282    { name(Value, Codes)
  283    }.
  284
  285peek_eof, ":" --> ":".
  286peek_eof --> eos.
  287
  288
  289%!  http_write_passwd_file(+File, +Data:list) is det.
  290%
  291%   Write password data Data to File. Data   is a list of entries as
  292%   below. See http_read_passwd_file/2 for details.
  293%
  294%     ==
  295%     passwd(User, Hash, Fields)
  296%     ==
  297%
  298%   @tbd    Write to a new file and atomically replace the old one.
  299
  300http_write_passwd_file(File, Data) :-
  301    must_be(list, Data),
  302    maplist(valid_data, Data),
  303    setup_call_cleanup(
  304        open(File, write, Out, [encoding(utf8)]),
  305        maplist(write_data(Out), Data),
  306        close(Out)),
  307    retractall(last_modified(File, _)). % flush cache
  308
  309valid_data(passwd(User, Hash, Fields)) :-
  310    !,
  311    valid_field(User),
  312    valid_field(Hash),
  313    must_be(list, Fields),
  314    maplist(valid_field, Fields).
  315valid_data(Data) :-
  316    type_error(passwd_entry, Data).
  317
  318valid_field(Field) :-
  319    must_be(atomic, Field),
  320    (   number(Field)
  321    ->  true
  322    ;   sub_string(Field, _, _, _, ":")
  323    ->  representation_error(passwd_field)
  324    ;   true
  325    ).
  326
  327write_data(Out, passwd(User, Hash, Fields)) :-
  328    atomics_to_string([User, Hash|Fields], ":", String),
  329    format(Out, '~s~n', [String]).
  330
  331
  332                 /*******************************
  333                 *   PLUGIN FOR HTTP_DISPATCH   *
  334                 *******************************/
  335
  336:- multifile
  337    http:authenticate/3.  338
  339%!  http:authenticate(+AuthData, +Request, -Fields)
  340%
  341%   Plugin  for  library(http_dispatch)  to    perform   basic  HTTP
  342%   authentication.
  343%
  344%   This predicate throws http_reply(authorise(basic, Realm)).
  345%
  346%   @arg    AuthData must be a term basic(File, Realm)
  347%   @arg    Request is the HTTP request
  348%   @arg    Fields describes the authenticated user with the option
  349%           user(User) and with the option user_details(Fields) if
  350%           the password file contains additional fields after the
  351%           user and password.
  352
  353http:authenticate(basic(File, Realm), Request,
  354                  [ user(User)
  355                  | Details
  356                  ]) :-
  357    (   http_authenticate(basic(File), Request, [User|Fields])
  358    ->  (   Fields == []
  359        ->  Details = []
  360        ;   Details = [user_details(Fields)]
  361        )
  362    ;   throw(http_reply(authorise(basic, Realm)))
  363    )