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 }