From: Jean Privat Date: Fri, 19 Aug 2016 20:20:19 +0000 (-0400) Subject: shibuqam: provide a basic auth web service to simplify the use of `shibuqam` X-Git-Url: http://nitlanguage.org shibuqam: provide a basic auth web service to simplify the use of `shibuqam` Signed-off-by: Jean Privat --- diff --git a/contrib/shibuqam/examples/shibuqamoauth.nit b/contrib/shibuqam/examples/shibuqamoauth.nit new file mode 100644 index 0000000..2a52be9 --- /dev/null +++ b/contrib/shibuqam/examples/shibuqamoauth.nit @@ -0,0 +1,339 @@ +# This file is part of NIT ( http://www.nitlanguage.org ). +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Server that implements an OAuth2-like authentication bound to a `shibuqam` valid server. +# +# The protocol is based on [OAuth2](https://tools.ietf.org/html/rfc6749), +# especially the [Authorization Code Grant](https://tools.ietf.org/html/rfc6749#section-4.1). +# +# # Use as a web service +# +# * User: the human user and its user-agent (browser) that use the client website. +# * Client: the 3rd party web site that need authentication. It owns `https://client.example.com` +# * Server: the public OAuth server. It owns `https://server.example.com` +# +# From the user & client point of view, the process is the following: +# +# 1. The client redirects the user to the server. +# 2. The user authenticates on the server. +# 3. The server redirects the user to the client with a token. +# 4. The client sends the token to the server. +# 5. The server responds with the user information. +# +# ## 1. User Request +# +# Two GET fields +# +# * `redirect_uri`: A full URI that will be used to redirect the user once the authentication is done. +# * `state`: A temporary string used to check that the callback is legitimate. +# +# On request, the service authenticate the user then redirect to the callback with the user informations. +# +# Example: +# +# ~~~raw +# GET https://server.example.com/login?redirect_uri=https://client.example.com/callback&state=secret +# ~~~ +# +# ## 3. Response +# +# After the authentication, the response is a 303 redirection to `redirect_uri` +# with the following GET arguments: +# +# * `code:` a temporary token to send-back to the server +# * `state`: the same state to check that the response is not forged. +# +# Example: +# +# ~~~raw +# HTTP/1.1 303 See Other +# Location: https://client.example.com/callback?code=something&state=secret +# ~~~ +# +# ## 3b. Errors +# +# If the request is badly formed or if the `redirect_uri` is not authorized, +# then there is no redirection and a text error message is send to the user. +# +# If there is a problem during the authentication, then there is a redirection +# but the GET fields are only `state` and `error=access_denied`. +# +# ## 4. Client request +# +# A single POST field +# +# * `code`: The one you get as the user response. +# +# Example: +# +# ~~~raw +# POST https://server.example.com/info +# code=something +# ~~~ +# +# ## 5. Response +# +# The response is a JSON object that is the plain serialization of a `User` instance. +# +# ~~~json +# HTTP/1.1 200 OK +# Content-Type: application/json;charset=UTF-8 +# +# { +# "id": "jdoe", +# "given_name": "John", +# "display_name": "John Doe", +# "email": "doe.john@example.com", +# "avatar": "https://www.gravatar.com/avatar/4fe50a575e1c28773800a0aa03c62dbe?d=retro" +# } +# ~~~ +# +# # To configure and execute +# +# ## Run the server +# +# shibuqamoauth authorized_list [host] [port] +# +# `authorized_list` is a textfile that registers the list of accepted `redirect_uri`. +# +# Example: +# +# ~~~raw +# https://example.com/foo +# https://other.example.com/ +# ~~~ +# +# To be authorized, a `redirect_uri` must have one of the line of `authorized_list` as a prefix. +# For instance, the previous `authorized_list` accepts `https://example.com/foo` and `https://example.com/foo/bar` +# but not `https://example.com/bar` nor `https://sub.example.com/foo`. +# +# +# ## Install as a server +# +# A correct `shibuqam` reverse proxy must be configured (see `shibuqam` for details). +# In a full scenario, the server is replaced by: +# +# * Proxy: the public reverse proxy that can do the genuine Shibboleth authentication. +# It owns `https://server.example.com` +# * Service: the private shibuqamoauth service. It owns a NATed `https://shibuqamoauth.example.com/` +# +# On the first access to the server (user request): +# +# 1. Proxy gets the request +# 2. Proxy does the Shibboleth authentication. +# 3. Proxy enhances the request header. +# 4. Proxy forwards the request to service. +# 5. Service checks that `redir` is authorized. +# 6. Service digs in the enhanced request header to generate a token and associate it to the user. +# 7. Redirect the user to client with the callback and the token. +# +# On the second access to the server (client request): +# +# 1. Proxy gets the request +# 2. Proxy forwards the request to service without authentification (it is not a user). +# 3. Service checks that the token existe and is not expired. +# 4. Service serialize and send the user as in the +# +# The Proxy should only do the authentication on the user request. +# The simplest way is to configure two routes that reverse proxy on the same server. +# +# # Why not using real OAuth2? +# +# OAuth2 is centered about *access_tokens* that allow clients to performs +# a (possibly scoped) set of actions/queries on the behalf of an authenticated user. +# +# In our scenario, there is no action/queries to do once an user is authenticated. +# So we do not delivers *access_tokens* since there is nothing to access once the user is known. +# +# We need only the third-party authentication part of the protocol. +# +# Since we do not have the same goals than the RFC, we also have a simplified protocol. +# Noways, OAuth is mainly a framework and the implementations are very diverse and unfortunately no +# not interoperable anyway. +# +# Here is the specific changes we have: +# +# * no `response_type`: there is a single kind of grant and the two steps are identified by the http method (GET or POST) and the configured routes on the server. +# * no `client_id`: because we do not want to code a db of clients. The `redirect_uri` can be seen as a simple client_id. We have also no way to present the client to the user since we do not control the authorization page of shibboleth since is done by the reverse proxy. +# * no `scope` and no `authorisation_code` since since there is nothing to access. We just get minimal information after the successful shibboleth login. Thus we have nothing more to authorise once the user is authenticated. +# * no `client_secret`: the user information are already public. There is no need to make the code more complex to protect public information. +module shibuqamoauth + +import popcorn +import shibuqam +import json::serialization + +redef class HttpRequest + # percent decoded get or post parameter. + fun dec_param(name: String): nullable String + do + var res = get_args.get_or_null(name) + if res == null then res = post_args.get_or_null(name) + if res == null then return null + return res.from_percent_encoding + end +end + +class AuthHandler + super Handler + + # List of prefixes of authorized redirections. + var authorized: Array[String] + + # Check is `redir` is an authorized redirection (client) + fun is_authorized(redir: String): Bool + do + for r in authorized do if redir.has_prefix(r) then return true + return false + end + + + # Associate each `code` issued to the user, to the info intended to the client. + var infos = new HashMap[String, Info] + + # Remove expired keys + fun expiration + do + var now = get_time + for k, i in infos do + if i.expiration < now then + infos.keys.remove(k) + print "{i.user.id} -> expired" + end + end + end + + # Generate a new usable token + # + # Not thread safe! + fun new_token: String + do + var token + loop + token = generate_token + if not infos.has_key(token) then break + end + return token + end + + redef fun get(http_request, response) + do + # GET means a Authorization Request from the user-agent + + # Get the `redirect_uri` parameter, we use it to identify the client + var redir = http_request.dec_param("redir") + if redir == null then redir = http_request.dec_param("redirect_uri") + if redir == null then + response.send("No redirect_uri.", 400) + return + end + + # Check if the client is authorized + if not is_authorized(redir) then + response.send("Site not authorized.", 403) + return + end + + # Get the state, we use it to avoid CSRF attacks + var state = http_request.dec_param("state") + var res = redir + "?" + + # If we are here, the reverse proxy did the authentication + # Is there an user? + var user = http_request.user + if user == null then + print "no user -> {redir}" + res += "error=access_denied" + else + # The user is authenticated. + print "{user.id} -> {redir}" + + # We prepare a token (code) that the client will use to get the information. + expiration + var token = new_token + + res += "code={token.to_percent_encoding}" + + var ttl = 10*60*60 # 10 minutes + var info = new Info(get_time + ttl, user) + infos[token] = info + end + if state != null then res += "&state={state.to_percent_encoding}" + response.redirect res + end + + redef fun post(http_request, response) + do + # POST means an Access Token Request from the client. + # Unfortunately, we have no access to grant, only informations. + print http_request.post_args.join(" ; ", ": ") + + expiration + + var code = http_request.dec_param("code") + if code == null then + print "POST: no code" + return + end + var info = infos.get_or_null(code) + if info == null then + print "POST: bad code {code}" + return + end + + print "{info.user.id} -> retrieved" + + # Drop the code as it is a single use + infos.keys.remove(code) + + # Send the requested information. + response.json(info.user) + end +end + +redef class User + super Jsonable + redef fun to_json do return serialize_to_json(plain=true) +end + +# Information about an authenticated used stored on the server to be given to the client. +class Info + # Time to live + var expiration: Int + + # The identified user + var user: User +end + +var host = "localhost" +var port = 3000 + +if args.is_empty then + print "usage: shibuqamoauth authorized_list [host] [port]" + return +end + +var list = args[0] +if args.length > 1 then host = args[1] +if args.length > 2 then port = args[2].to_i + +var authorized = list.to_path.read_lines +if authorized.is_empty then + print_error "{list}: not found or empty" + exit 1 +end + +var app = new App +app.use("/*", new AuthHandler(authorized)) +app.listen(host, port)