using System; using System.Text; using System.Net; using System.IO; using System.Collections; using System.Collections.Specialized; using System.Security.Cryptography; using Mono.Security.Cryptography; using Janrain.OpenId.Store; namespace Janrain.OpenId.Consumer { public class ParseException : ApplicationException { } public class MissingParameterException : ApplicationException { public MissingParameterException(string key) : base(String.Format("NameValueCollection missing key: {0}", key)) { } } public struct AuthRequest { public string token; public string nonce; public Uri serverId; public Uri serverUri; } /// /// This class is the gateway to the OpenId consumer logic. /// Instances of it maintain no per-request state, so they can be /// reused (or even used by multiple threads concurrently) as needed. /// public class Consumer { public enum Mode { IMMEDIATE, SETUP } public enum Status { SUCCESS, FAILURE, SETUP_NEEDED } static uint NONCE_LEN = 8; static byte[] NONCE_CHARS = new byte[62]; static uint TOKEN_LIFETIME = 120; IAssociationStore store; Fetcher fetcher; static Consumer() { uint i = 0; uint j; for (j = 97; j < 123; i++, j++) NONCE_CHARS[i] = (byte)j; for (j = 65; j < 91; i++, j++) NONCE_CHARS[i] = (byte)j; for (j = 48; j < 58; i++, j++) NONCE_CHARS[i] = (byte)j; } /// /// Initializes a new instance. /// public Consumer(IAssociationStore store, Fetcher fetcher) { this.store = store; this.fetcher = fetcher; } /// /// This method is called to start the OpenId login process. /// /// /// /// To create the parameter from /// the text submitted by the user, use the NormalizeUri /// method of . /// /// /// /// This is the url the user entered as their OpenId. /// /// /// /// This method returns an instance of /// representing the state of /// the authorization request. /// public AuthRequest BeginAuth(Uri userUrl) { // Retrieve the users info from their openid page FetchResponse response = this.fetcher.Get(userUrl); AuthRequest request = ParseIdentityInfo(response); // Create a nonce for this openid exchange byte[] nonce = new byte[NONCE_LEN]; CryptUtil.RandomSelection(nonce, NONCE_CHARS); request.nonce = ASCIIEncoding.ASCII.GetString(nonce); // Added a signed token to the request GenToken(response.finalUri, ref request); return request; } /// /// This method is called to construct the redirect URL sent /// to the browser to ask the server to verify its identity. /// The generated redirect should be sent to the browser /// which initiated the authorization request. /// /// /// /// An instance of as returned /// from BeginAuth. /// /// /// The URL the identity server should redirect back to. /// /// /// This represents the consumer to the identity server. For example, /// an ASP application would probably send an absolute URL using /// the Application path. The OpenId spec, /// http://www.openid.net/specs.bml#mode-checkid_immediate, /// has more information on what the trust_root value is for /// and what its form can be. /// /// /// /// This method returns a /// representing the URL to redirect to when such a URL is /// successfully constructed. /// public Uri CreateRedirect(Mode mode, AuthRequest request, Uri returnTo, string trustRoot) { Association assoc = GetAssociation(request.serverUri, true); UriBuilder redir = new UriBuilder(request.serverUri); UriUtil.AppendQueryArgument(redir, "openid.identity", request.serverId.AbsoluteUri); UriUtil.AppendQueryArgument(redir, "openid.return_to", returnTo.AbsoluteUri); UriUtil.AppendQueryArgument(redir, "openid.trust_root", trustRoot); switch (mode) { case Mode.IMMEDIATE: UriUtil.AppendQueryArgument(redir, "openid.mode", "checkid_immediate"); break; case Mode.SETUP: UriUtil.AppendQueryArgument(redir, "openid.mode", "checkid_setup"); break; } if (assoc != null) UriUtil.AppendQueryArgument(redir, "openid.assoc_handle", assoc.Handle); this.store.StoreNonce(request.nonce); return new Uri(redir.ToString(), true); } /// /// This method is called to interpret the server's response /// to an OpenID request. /// /// /// This is the token for this authentication transaction, /// generated by the call to BeginAuth. /// /// /// Should contain the query parameters the OpenID server /// included in its redirect back to the return_to URL. The /// keys and values should both be url-unescaped. /// /// /// This output parameter is the data associated with the returned /// status. /// /// /// When SUCCESS is returned, the additional information /// returned is either null or a . /// If it is null, it means the user cancelled the login, and /// no further information can be determined. If the /// additional information is a , it /// is the identity that has been verified as belonging to /// the user making this request. /// /// When FAILURE is returned, the additional information is /// either null or a . In either /// case, this code means that the identity verification /// failed. If it can be determined, the identity that /// failed to verify is returned. Otherwise null is returned. /// /// When SETUP_NEEDED is returned, the additional information /// is the user setup URL of type . /// This is a URL returned only as a response to requests /// made with the IMMEDIATE mode, which indicates that the /// login was unable to proceed, and the user should be sent /// to that URL if they wish to proceed with the login. /// public Status CompleteAuth(string token, NameValueCollection query, out object info) { string mode = query["openid.mode"]; if (mode != null) { switch (mode) { case "cancel": info = null; return Status.SUCCESS; case "error": string error = query["openid.error"]; if (error != null) { // XXX: log this error } break; case "id_res": return DoIdRes(token, query, out info); } } else { // XXX: log this error } info = null; return Status.FAILURE; } private Association GetAssociation(Uri serverUri, bool replace) { if (this.store.IsDumb) return null; Association assoc = this.store.GetAssociation(serverUri); if (assoc == null || (replace && (assoc.ExpiresIn < TOKEN_LIFETIME))) { DiffieHellman dh = CryptUtil.CreateDiffieHellman(); byte[] body = CreateAssociateRequest(dh); assoc = FetchAssociation(dh, serverUri, body); } return assoc; } private void GenToken (Uri consumerId, ref AuthRequest request) { string timestamp = DateTime.UtcNow.ToFileTimeUtc().ToString(); MemoryStream ms = new MemoryStream(); byte[] temp = ASCIIEncoding.ASCII.GetBytes(timestamp); ms.Write(temp, 0, temp.Length); ms.WriteByte(0); temp = ASCIIEncoding.ASCII.GetBytes(request.nonce); ms.Write(temp, 0, temp.Length); ms.WriteByte(0); temp = ASCIIEncoding.ASCII.GetBytes(consumerId.AbsoluteUri); ms.Write(temp, 0, temp.Length); ms.WriteByte(0); temp = ASCIIEncoding.ASCII.GetBytes(request.serverId.AbsoluteUri); ms.Write(temp, 0, temp.Length); ms.WriteByte(0); temp = ASCIIEncoding.ASCII.GetBytes(request.serverUri.AbsoluteUri); ms.Write(temp, 0, temp.Length); HMACSHA1 hmac = new HMACSHA1(this.store.AuthKey); byte[] hash = hmac.ComputeHash(ms); MemoryStream ms2 = new MemoryStream(); ms2.Write(hash, 0, hash.Length); ms.WriteTo(ms2); request.token = CryptUtil.ToBase64String(ms2.ToArray()); } private Association FetchAssociation(DiffieHellman dh, Uri serverUri, byte[] body) { try { FetchResponse resp = this.fetcher.Post(serverUri, body); NameValueCollection results = KVUtil.KVToDict(resp.data); return ParseAssociation(results, dh, serverUri); } catch (FetchException e) { if (e.response == null) { // XXX: log network failure } else if (e.response.code == HttpStatusCode.BadRequest) { // XXX: log this /* server_error = results.get('error', '') fmt = 'Getting association: error returned from server %s: %s' oidutil.log(fmt % (server_url, server_error)) */ } else { // XXX: log this /*fmt = 'Getting association: bad status code from server %s: %s' oidutil.log(fmt % (server_url, http_code)) */ } return null; } } private string GetParameter(NameValueCollection args, string key) { string val = args[key]; if (val == null) throw new MissingParameterException(key); return val; } protected Association ParseAssociation(NameValueCollection results, DiffieHellman dh, Uri serverUri) { try { if (GetParameter(results, "assoc_type") != "HMAC-SHA1") { // XXX: log this return null; } byte[] secret; string sessionType = results["session_type"]; if (sessionType == null) { string macKey = results["mac_key"]; if (macKey == null) { // XXX: Log this return null; } secret = Convert.FromBase64String(macKey); } else { if (sessionType != "DH-SHA1") { // XXX: log this return null; } byte[] spub = Convert.FromBase64String(GetParameter(results, "dh_server_public")); byte[] encMacKey = Convert.FromBase64String(GetParameter(results, "enc_mac_key")); secret = CryptUtil.SHA1XorSecret(dh, spub, encMacKey); } string assocHandle = GetParameter(results, "assoc_handle"); TimeSpan expiresIn = new TimeSpan(0, 0, Convert.ToInt32(GetParameter(results, "expires_in"))); Association assoc = new HMACSHA1Association(assocHandle, secret, expiresIn); this.store.StoreAssociation(serverUri, assoc); return assoc; } catch (MissingParameterException e) { // XXX: log this return null; } } private Status DoIdRes (string token, NameValueCollection query, out object info) { string nonce; Uri consumerId, serverId, serverUri; if ( ! SplitToken(token, out nonce, out consumerId, out serverId, out serverUri) ) { // XXX: log this info = null; return Status.FAILURE; } try { string userSetup = query["openid.user_setup_url"]; if (userSetup != null) { info = new Uri(userSetup); return Status.SETUP_NEEDED; } GetParameter(query, "openid.return_to"); string serverId2 = GetParameter(query, "openid.identity"); string assocHandle = GetParameter(query, "openid.assoc_handle"); if (serverId.AbsoluteUri != serverId2) { // XXX: log this info = null; return Status.FAILURE; } Association assoc = this.store.GetAssociation(serverUri, assocHandle); if (assoc == null ) { // It's not an association we know about. Dumb // mode is our only chance for recovery. info = consumerId; return CheckAuth(nonce, query, serverUri); } if (assoc.ExpiresIn <= 0) { /* XXX: It might be a good idea sometimes to re-start the authentication with a new association. Doing it automatically opens the possibility for denial-of-service by a server that just returns expired associations (or really short-lived associations) */ // XXX: Log this info = consumerId; return Status.FAILURE; } // Check the signature string sig = GetParameter(query, "openid.sig"); string signed = GetParameter(query, "openid.signed"); string[] signedArray = signed.Split(new char[] { ',', }); string vSig = assoc.SignDict(signedArray, query, "openid."); if (vSig == sig) { if (this.store.UseNonce(nonce)) { info = consumerId; return Status.SUCCESS; } else { // XXX: log this } } else { // XXX: log this } } catch (MissingParameterException e) { // XXX: log this } info = consumerId; return Status.FAILURE; } private bool SplitToken(string token, out string nonce, out Uri consumerId, out Uri serverId, out Uri serverUri) { nonce = null; consumerId = null; serverId = null; serverUri = null; byte[] tok = Convert.FromBase64String(token); if (tok.Length < 21) return false; byte[] sig = new byte[20]; for (uint i = 0; i < sig.Length; i++) sig[i] = tok[i]; int idx, prev = 20; byte delim = 0x00; // Parse timestamp if ((idx = Array.IndexOf(tok, delim, prev)) == -1) return false; string timestamp = ASCIIEncoding.ASCII.GetString( tok, prev, idx - prev); prev = idx + 1; // Check if timestamp has expired DateTime ts = DateTime.FromFileTimeUtc(Convert.ToInt64(timestamp)); ts += new TimeSpan(0, 0, (int) TOKEN_LIFETIME); if (ts < DateTime.UtcNow) { // XXX: log this return false; } // Parse nonce if ((idx = Array.IndexOf(tok, delim, prev)) == -1) { // XXX: log this return false; } nonce = ASCIIEncoding.ASCII.GetString(tok, prev, idx - prev); prev = idx + 1; // Parse consumerId if ((idx = Array.IndexOf(tok, delim, prev)) == -1) { // XXX: log this return false; } consumerId = new Uri(ASCIIEncoding.ASCII.GetString(tok, prev, idx - prev)); prev = idx + 1; // Parse serverId if ((idx = Array.IndexOf(tok, delim, prev)) == -1) { // XXX: log this return false; } serverId = new Uri(ASCIIEncoding.ASCII.GetString(tok, prev, idx - prev)); prev = idx + 1; // Parse serverUri serverUri = new Uri(ASCIIEncoding.ASCII.GetString(tok, prev, tok.Length - prev)); return true; } private Status CheckAuth(string nonce, NameValueCollection query, Uri serverUri) { NameValueCollection checkArgs = new NameValueCollection(); foreach (DictionaryEntry pair in query) { string key = pair.Key as string; if (key.StartsWith("openid.")) checkArgs.Add(key, pair.Value as string); } checkArgs["openid.mode"] = "check_authentication"; string postString = UriUtil.CreateQueryString(checkArgs); byte[] postData = ASCIIEncoding.ASCII.GetBytes(postString); try { FetchResponse resp = this.fetcher.Post(serverUri, postData); NameValueCollection results = KVUtil.KVToDict(resp.data); string isValid = results["is_valid"]; if (isValid == "true") { string invalidateHandle = results["invalidate_handle"]; if (invalidateHandle != null) this.store.RemoveAssociation(serverUri, invalidateHandle); if (this.store.UseNonce(nonce)) return Status.SUCCESS; } else { string error = results["error"]; if (error != null) { // XXX: Log this error string } } } catch (FetchException e) { // XXX: Log this fetch error } return Status.FAILURE; } /**************** Static Methods *****************/ public static byte[] CreateAssociationRequest (DiffieHellman dh, NameValueCollection args) { byte[] dhPublic = dh.CreateKeyExchange(); string cpub = CryptUtil.UnsignedToBase64(dhPublic); DHParameters dhps = dh.ExportParameters(true); args.Add("openid.mode", "associate"); args.Add("openid.assoc_type", "HMAC-SHA1"); args.Add("openid.session_type", "DH-SHA1"); args.Add("openid.dh_modulus", CryptUtil.UnsignedToBase64(dhps.P)); args.Add("openid.dh_gen", CryptUtil.UnsignedToBase64(dhps.G)); args.Add("openid.dh_consumer_public", cpub); return ASCIIEncoding.ASCII.GetBytes(UriUtil.CreateQueryString(args)); } private static byte[] CreateAssociateRequest(DiffieHellman dh) { NameValueCollection args = new NameValueCollection(); return CreateAssociationRequest(dh, args); } private static AuthRequest ParseIdentityInfo(FetchResponse response) { string server = null; string deleg = null; string rel, href; foreach (NameValueCollection attrs in LinkParser.ParseLinkAttrs(response.data, response.length, response.charset)) { rel = attrs["rel"]; if (rel != null) { href = attrs["href"]; if (rel == "openid.server" && server == null) if (href != null) server = href; if (rel == "openid.delegate" && deleg == null) if (href != null) deleg = href; } } if (server == null) throw new ParseException(); AuthRequest request = new AuthRequest(); request.serverUri = UriUtil.NormalizeUri(server); if (deleg == null) request.serverId = response.finalUri; else request.serverId = UriUtil.NormalizeUri(deleg); return request; } } }