1 /** 2 Implements HTTP Digest Authentication. 3 4 This is a minimal implementation based on RFC 2069. 5 6 Copyright: © 2015 Sönke Ludwig 7 License: Subject to the terms of the MIT license, as written in the included LICENSE.txt file. 8 Authors: Kai Nacke 9 */ 10 module vibe.http.auth.digest_auth; 11 12 import vibe.core.log; 13 import vibe.crypto.cryptorand; 14 import vibe.http.server; 15 import vibe.inet.url; 16 17 import std.base64; 18 import std.datetime; 19 import std.digest.md; 20 import std.exception; 21 import std.string; 22 23 @safe: 24 25 enum NonceState { Valid, Expired, Invalid } 26 27 class DigestAuthInfo 28 { 29 @safe: 30 31 string realm; 32 ubyte[16] secret; 33 Duration timeout; 34 35 this() 36 { 37 secureRNG.read(secret[]); 38 timeout = 300.seconds; 39 } 40 41 string createNonce(scope const HTTPServerRequest req) 42 { 43 auto now = Clock.currTime(UTC()).stdTime(); 44 auto time = () @trusted { return *cast(ubyte[now.sizeof]*)&now; } (); 45 MD5 md5; 46 md5.put(time); 47 md5.put(secret); 48 auto data = md5.finish(); 49 return Base64.encode(time ~ data); 50 } 51 52 NonceState checkNonce(string nonce, scope const HTTPServerRequest req) 53 { 54 auto now = Clock.currTime(UTC()).stdTime(); 55 ubyte[] decoded = Base64.decode(nonce); 56 if (decoded.length != now.sizeof + secret.length) return NonceState.Invalid; 57 auto timebytes = decoded[0 .. now.sizeof]; 58 auto time = () @trusted { return (cast(typeof(now)[])timebytes)[0]; } (); 59 if (timeout.total!"hnsecs" + time < now) return NonceState.Expired; 60 MD5 md5; 61 md5.put(timebytes); 62 md5.put(secret); 63 auto data = md5.finish(); 64 if (data[] != decoded[now.sizeof .. $]) return NonceState.Invalid; 65 return NonceState.Valid; 66 } 67 } 68 69 unittest 70 { 71 auto authInfo = new DigestAuthInfo; 72 auto req = createTestHTTPServerRequest(URL("http://localhost/")); 73 auto nonce = authInfo.createNonce(req); 74 assert(authInfo.checkNonce(nonce, req) == NonceState.Valid); 75 } 76 77 private bool checkDigest(scope HTTPServerRequest req, DigestAuthInfo info, scope DigestHashCallback pwhash, out bool stale, out string username) 78 { 79 stale = false; 80 username = ""; 81 auto pauth = "Authorization" in req.headers; 82 83 if (pauth && (*pauth).startsWith("Digest ")) { 84 string realm, nonce, response, uri, algorithm; 85 foreach (param; split((*pauth)[7 .. $], ",")) { 86 auto kv = split(param, "="); 87 switch (kv[0].strip().toLower()) { 88 default: break; 89 case "realm": realm = param.stripLeft()[7..$-1]; break; 90 case "username": username = param.stripLeft()[10..$-1]; break; 91 case "nonce": nonce = kv[1][1..$-1]; break; 92 case "uri": uri = param.stripLeft()[5..$-1]; break; 93 case "response": response = kv[1][1..$-1]; break; 94 case "algorithm": algorithm = kv[1][1..$-1]; break; 95 } 96 } 97 98 if (realm != info.realm) 99 return false; 100 if (algorithm !is null && algorithm != "MD5") 101 return false; 102 103 auto nonceState = info.checkNonce(nonce, req); 104 if (nonceState != NonceState.Valid) { 105 stale = nonceState == NonceState.Expired; 106 return false; 107 } 108 109 auto ha1 = pwhash(realm, username); 110 auto ha2 = toHexString!(LetterCase.lower)(md5Of(httpMethodString(req.method) ~ ":" ~ uri)); 111 auto calcresponse = toHexString!(LetterCase.lower)(md5Of(ha1 ~ ":" ~ nonce ~ ":" ~ ha2 )); 112 if (response[] == calcresponse[]) 113 return true; 114 } 115 return false; 116 } 117 118 /** 119 Returns a request handler that enforces request to be authenticated using HTTP Digest Auth. 120 */ 121 HTTPServerRequestDelegate performDigestAuth(DigestAuthInfo info, scope DigestHashCallback pwhash) 122 { 123 void handleRequest(HTTPServerRequest req, HTTPServerResponse res) 124 @safe { 125 bool stale; 126 string username; 127 if (checkDigest(req, info, pwhash, stale, username)) { 128 req.username = username; 129 return ; 130 } 131 132 // else output an error page 133 res.statusCode = HTTPStatus.unauthorized; 134 res.contentType = "text/plain"; 135 res.headers["WWW-Authenticate"] = "Digest realm=\""~info.realm~"\", nonce=\""~info.createNonce(req)~"\", stale="~(stale?"true":"false"); 136 res.bodyWriter.write("Authorization required"); 137 } 138 return &handleRequest; 139 } 140 /// Scheduled for deprecation - use a `@safe` callback instead. 141 HTTPServerRequestDelegate performDigestAuth(DigestAuthInfo info, scope string delegate(string, string) @system pwhash) 142 @system { 143 return performDigestAuth(info, (r, u) @trusted => pwhash(r, u)); 144 } 145 146 /** 147 Enforces HTTP Digest Auth authentication on the given req/res pair. 148 149 Params: 150 req = Request object that is to be checked 151 res = Response object that will be used for authentication errors 152 info = Digest authentication info object 153 pwhash = A delegate queried for returning the digest password 154 155 Returns: Returns the name of the authenticated user. 156 157 Throws: Throws a HTTPStatusExeption in case of an authentication failure. 158 */ 159 string performDigestAuth(scope HTTPServerRequest req, scope HTTPServerResponse res, DigestAuthInfo info, scope DigestHashCallback pwhash) 160 { 161 bool stale; 162 string username; 163 if (checkDigest(req, info, pwhash, stale, username)) 164 return username; 165 166 res.headers["WWW-Authenticate"] = "Digest realm=\""~info.realm~"\", nonce=\""~info.createNonce(req)~"\", stale="~(stale?"true":"false"); 167 throw new HTTPStatusException(HTTPStatus.unauthorized); 168 } 169 /// Scheduled for deprecation - use a `@safe` callback instead. 170 string performDigestAuth(scope HTTPServerRequest req, scope HTTPServerResponse res, DigestAuthInfo info, scope string delegate(string, string) @system pwhash) 171 @system { 172 return performDigestAuth(req, res, info, (r, u) @trusted => pwhash(r, u)); 173 } 174 175 /** 176 Creates the digest password from the user name, realm and password. 177 178 Params: 179 realm = The realm 180 user = The user name 181 password = The plain text password 182 183 Returns: Returns the digest password 184 */ 185 string createDigestPassword(string realm, string user, string password) 186 { 187 return toHexString!(LetterCase.lower)(md5Of(user ~ ":" ~ realm ~ ":" ~ password)).dup; 188 } 189 190 alias DigestHashCallback = string delegate(string realm, string user); 191 192 /// Structure which describes requirements of the digest authentication - see https://tools.ietf.org/html/rfc2617 193 struct DigestAuthParams { 194 enum Qop { none = 0, auth = 1, auth_int = 2 } 195 enum Algorithm { none = 0, md5 = 1, md5_sess = 2 } 196 197 string realm, domain, nonce, opaque; 198 Algorithm algorithm = Algorithm.md5; 199 bool stale; 200 Qop qop; 201 202 /// Parses WWW-Authenticate header value with the digest parameters 203 this(string auth) { 204 import std.algorithm : splitter; 205 206 assert(auth.startsWith("Digest "), "Correct Digest authentication request not provided"); 207 208 foreach (param; auth["Digest ".length..$].splitter(',')) 209 { 210 auto idx = param.indexOf("="); 211 if (idx <= 0) { 212 logError("Invalid parameter in auth header: %s (%s)", param, auth); 213 continue; 214 } 215 auto k = param[0..idx]; 216 auto v = param[idx+1..$]; 217 switch (k.strip().toLower()) { 218 default: break; 219 case "realm": realm = v[1..$-1]; break; 220 case "domain": domain = v[1..$-1]; break; 221 case "nonce": nonce = v[1..$-1]; break; 222 case "opaque": opaque = v[1..$-1]; break; 223 case "stale": stale = v.toLower() == "true"; break; 224 case "algorithm": 225 switch (v) { 226 default: break; 227 case "MD5": algorithm = Algorithm.md5; break; 228 case "MD5-sess": algorithm = Algorithm.md5_sess; break; 229 } 230 break; 231 case "qop": 232 foreach (q; v[1..$-1].splitter(',')) { 233 switch (q) { 234 default: break; 235 case "auth": qop |= Qop.auth; break; 236 case "auth-int": qop |= Qop.auth_int; break; 237 } 238 } 239 break; 240 } 241 } 242 } 243 } 244 245 /** 246 Creates the digest authorization request header. 247 248 Params: 249 method = HTTP method (required only when some qop is requested) 250 username = user name 251 password = user password 252 url = requested url 253 auth = value from the WWW-Authenticate response header 254 cnonce = client generated unique data string (required only when some qop is requested) 255 nc = the count of requests sent by the client (required only when some qop is requested) 256 entityBody = request entity body required only if qop==auth-int 257 */ 258 auto createDigestAuthHeader(U)(HTTPMethod method, U url, string username, string password, DigestAuthParams auth, 259 string cnonce = null, int nc = 0, in ubyte[] entityBody = null) 260 if (is(U == string) || is(U == URL)) { 261 262 import std.array : appender; 263 import std.format : formattedWrite; 264 265 auto getHA1(string username, string password, string realm, string nonce = null, string cnonce = null) { 266 267 assert((nonce is null && cnonce is null) || (nonce !is null && cnonce !is null)); 268 269 auto ha1 = toHexString!(LetterCase.lower)(md5Of(format("%s:%s:%s", username, realm, password))).dup; 270 if (nonce !is null) ha1 = toHexString!(LetterCase.lower)(md5Of(format("%s:%s:%s", ha1, nonce, cnonce))).dup; 271 return ha1; 272 } 273 274 auto getHA2(HTTPMethod method, string uri, in ubyte[] ebody = null) { 275 return ebody is null 276 ? toHexString!(LetterCase.lower)(md5Of(format("%s:%s", method, uri))).dup 277 : toHexString!(LetterCase.lower)(md5Of(format("%s:%s:%s", method, uri, toHexString!(LetterCase.lower)(md5Of(ebody)).dup))).dup; 278 } 279 280 static if (is(U == string)) auto uri = URL(url).pathString; 281 else auto uri = url.pathString; 282 283 auto dig = appender!string(); 284 dig ~= "Digest "; 285 dig ~= `username="`; dig ~= username; dig ~= `", `; 286 dig ~= `realm="`; dig ~= auth.realm; dig ~= `", `; 287 dig ~= `nonce="`; dig ~= auth.nonce; dig ~= `", `; 288 dig ~= `uri="`; dig ~= uri; dig ~= `", `; 289 if (auth.opaque.length) { dig ~= `opaque="`; dig ~= auth.opaque; dig ~= `", `; } 290 291 //choose one of provided qop 292 DigestAuthParams.Qop qop; 293 if ((auth.qop & DigestAuthParams.Qop.auth) == DigestAuthParams.Qop.auth) qop = DigestAuthParams.Qop.auth; 294 else if ((auth.qop & DigestAuthParams.Qop.auth_int) == DigestAuthParams.Qop.auth_int) qop = DigestAuthParams.Qop.auth_int; 295 296 if (qop != DigestAuthParams.Qop.none) { 297 assert(cnonce !is null, "cnonce is required"); 298 assert(nc != 0, "nc is required"); 299 300 dig ~= `qop="`; dig ~= qop == DigestAuthParams.Qop.auth ? "auth" : "auth-int"; dig ~= `", `; 301 dig ~= `cnonce="`; dig ~= cnonce; dig ~= `", `; 302 dig ~= `nc="`; dig.formattedWrite("%08x", nc); dig ~= `", `; 303 } 304 305 auto ha1 = auth.algorithm == DigestAuthParams.Algorithm.md5_sess 306 ? getHA1(username, password, auth.realm, auth.nonce, cnonce) 307 : getHA1(username, password, auth.realm); 308 309 auto ha2 = qop != DigestAuthParams.Qop.auth_int 310 ? getHA2(method, uri) 311 : getHA2(method, uri, entityBody); 312 313 auto resp = qop == DigestAuthParams.Qop.none 314 ? toHexString!(LetterCase.lower)(md5Of(format("%s:%s:%s", ha1, auth.nonce, ha2))).dup 315 : toHexString!(LetterCase.lower)(md5Of(format("%s:%s:%08x:%s:%s:%s", ha1, auth.nonce, nc, cnonce, qop == DigestAuthParams.Qop.auth ? "auth" : "auth-int" , ha2))).dup; 316 317 dig ~= `response="`; dig ~= resp; dig ~= `"`; 318 319 return dig.data; 320 }