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[32] secret;
33 	ulong timeout;
34 
35 	this()
36 	{
37 		secureRNG.read(secret[]);
38 		timeout = 300;
39 	}
40 
41 	string createNonce(in 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(in string nonce, in 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 + 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 private bool checkDigest(scope HTTPServerRequest req, DigestAuthInfo info, scope DigestHashCallback pwhash, out bool stale, out string username)
70 {
71 	stale = false;
72 	username = "";
73 	auto pauth = "Authorization" in req.headers;
74 
75 	if (pauth && (*pauth).startsWith("Digest ")) {
76 		string realm, nonce, response, uri, algorithm;
77 		foreach (param; split((*pauth)[7 .. $], ",")) {
78 			auto kv = split(param, "=");
79 			switch (kv[0].strip().toLower()) {
80 				default: break;
81 				case "realm": realm = param.stripLeft()[7..$-1]; break;
82 				case "username": username = param.stripLeft()[10..$-1]; break;
83 				case "nonce": nonce = kv[1][1..$-1]; break;
84 				case "uri": uri = param.stripLeft()[5..$-1]; break;
85 				case "response": response = kv[1][1..$-1]; break;
86 				case "algorithm": algorithm = kv[1][1..$-1]; break;
87 			}
88 		}
89 
90 		if (realm != info.realm)
91 			return false;
92 		if (algorithm !is null && algorithm != "MD5")
93 			return false;
94 
95 		auto nonceState = info.checkNonce(nonce, req);
96 		if (nonceState != NonceState.Valid) {
97 			stale = nonceState == NonceState.Expired;
98 			return false;
99 		}
100 
101 		auto ha1 = pwhash(realm, username);
102 		auto ha2 = toHexString!(LetterCase.lower)(md5Of(httpMethodString(req.method) ~ ":" ~ uri));
103 		auto calcresponse = toHexString!(LetterCase.lower)(md5Of(ha1 ~ ":" ~ nonce ~ ":" ~ ha2 ));
104 		if (response[] == calcresponse[])
105 			return true;
106 	}
107 	return false;
108 }
109 
110 /**
111 	Returns a request handler that enforces request to be authenticated using HTTP Digest Auth.
112 */
113 HTTPServerRequestDelegate performDigestAuth(DigestAuthInfo info, scope DigestHashCallback pwhash)
114 {
115 	void handleRequest(HTTPServerRequest req, HTTPServerResponse res)
116 	@safe {
117 		bool stale;
118 		string username;
119 		if (checkDigest(req, info, pwhash, stale, username)) {
120 			req.username = username;
121 			return ;
122 		}
123 
124 		// else output an error page
125 		res.statusCode = HTTPStatus.unauthorized;
126 		res.contentType = "text/plain";
127 		res.headers["WWW-Authenticate"] = "Digest realm=\""~info.realm~"\", nonce=\""~info.createNonce(req)~"\", stale="~(stale?"true":"false");
128 		res.bodyWriter.write("Authorization required");
129 	}
130 	return &handleRequest;
131 }
132 /// Scheduled for deprecation - use a `@safe` callback instead.
133 HTTPServerRequestDelegate performDigestAuth(DigestAuthInfo info, scope string delegate(string, string) @system pwhash)
134 @system {
135 	return performDigestAuth(info, (r, u) @trusted => pwhash(r, u));
136 }
137 
138 /**
139 	Enforces HTTP Digest Auth authentication on the given req/res pair.
140 
141 	Params:
142 		req = Request object that is to be checked
143 		res = Response object that will be used for authentication errors
144 		info = Digest authentication info object
145 		pwhash = A delegate queried for returning the digest password
146 
147 	Returns: Returns the name of the authenticated user.
148 
149 	Throws: Throws a HTTPStatusExeption in case of an authentication failure.
150 */
151 string performDigestAuth(scope HTTPServerRequest req, scope HTTPServerResponse res, DigestAuthInfo info, scope DigestHashCallback pwhash)
152 {
153 	bool stale;
154 	string username;
155 	if (checkDigest(req, info, pwhash, stale, username))
156 		return username;
157 
158 	res.headers["WWW-Authenticate"] = "Digest realm=\""~info.realm~"\", nonce=\""~info.createNonce(req)~"\", stale="~(stale?"true":"false");
159 	throw new HTTPStatusException(HTTPStatus.unauthorized);
160 }
161 /// Scheduled for deprecation - use a `@safe` callback instead.
162 string performDigestAuth(scope HTTPServerRequest req, scope HTTPServerResponse res, DigestAuthInfo info, scope string delegate(string, string) @system pwhash)
163 @system {
164 	return performDigestAuth(req, res, info, (r, u) @trusted => pwhash(r, u));
165 }
166 
167 /**
168 	Creates the digest password from the user name, realm and password.
169 
170 	Params:
171 		realm = The realm
172 		user = The user name
173 		password = The plain text password
174 
175 	Returns: Returns the digest password
176 */
177 string createDigestPassword(string realm, string user, string password)
178 {
179 	return toHexString!(LetterCase.lower)(md5Of(user ~ ":" ~ realm ~ ":" ~ password)).dup;
180 }
181 
182 alias DigestHashCallback = string delegate(string realm, string user);
183 
184 /// Structure which describes requirements of the digest authentication - see https://tools.ietf.org/html/rfc2617
185 struct DigestAuthParams {
186 	enum Qop { none = 0, auth = 1, auth_int = 2 }
187 	enum Algorithm { none = 0, md5 = 1, md5_sess = 2 }
188 
189 	string realm, domain, nonce, opaque;
190 	Algorithm algorithm = Algorithm.md5;
191 	bool stale;
192 	Qop qop;
193 
194 	/// Parses WWW-Authenticate header value with the digest parameters
195 	this(string auth) {
196 		import std.algorithm : splitter;
197 
198 		assert(auth.startsWith("Digest "), "Correct Digest authentication request not provided");
199 
200 		foreach (param; auth["Digest ".length..$].splitter(','))
201 		{
202 			auto idx = param.indexOf("=");
203 			if (idx <= 0) {
204 				logError("Invalid parameter in auth header: %s (%s)", param, auth);
205 				continue;
206 			}
207 			auto k = param[0..idx];
208 			auto v = param[idx+1..$];
209 			switch (k.strip().toLower()) {
210 				default: break;
211 				case "realm": realm = v[1..$-1]; break;
212 				case "domain": domain = v[1..$-1]; break;
213 				case "nonce": nonce = v[1..$-1]; break;
214 				case "opaque": opaque = v[1..$-1]; break;
215 				case "stale": stale = v.toLower() == "true"; break;
216 				case "algorithm":
217 					switch (v) {
218 						default: break;
219 						case "MD5": algorithm = Algorithm.md5; break;
220 						case "MD5-sess": algorithm = Algorithm.md5_sess; break;
221 					}
222 					break;
223 				case "qop":
224 					foreach (q; v[1..$-1].splitter(',')) {
225 						switch (q) {
226 							default: break;
227 							case "auth": qop |= Qop.auth; break;
228 							case "auth-int": qop |= Qop.auth_int; break;
229 						}
230 					}
231 					break;
232 			}
233 		}
234 	}
235 }
236 
237 /**
238 	Creates the digest authorization request header.
239 
240 	Params:
241 		method = HTTP method (required only when some qop is requested)
242 		username = user name
243 		password = user password
244 		url = requested url
245 		auth = value from the WWW-Authenticate response header
246 		cnonce = client generated unique data string (required only when some qop is requested)
247 		nc = the count of requests sent by the client (required only when some qop is requested)
248 		entityBody = request entity body required only if qop==auth-int
249 */
250 auto createDigestAuthHeader(U)(HTTPMethod method, U url, string username, string password, DigestAuthParams auth,
251 	string cnonce = null, int nc = 0, in ubyte[] entityBody = null)
252 if (is(U == string) || is(U == URL)) {
253 
254 	import std.array : appender;
255 	import std.format : formattedWrite;
256 
257 	auto getHA1(string username, string password, string realm, string nonce = null, string cnonce = null) {
258 
259 		assert((nonce is null && cnonce is null) || (nonce !is null && cnonce !is null));
260 
261 		auto ha1 = toHexString!(LetterCase.lower)(md5Of(format("%s:%s:%s", username, realm, password))).dup;
262 		if (nonce !is null) ha1 = toHexString!(LetterCase.lower)(md5Of(format("%s:%s:%s", ha1, nonce, cnonce))).dup;
263 		return ha1;
264 	}
265 
266 	auto getHA2(HTTPMethod method, string uri, in ubyte[] ebody = null) {
267 		return ebody is null
268 			? toHexString!(LetterCase.lower)(md5Of(format("%s:%s", method, uri))).dup
269 			: toHexString!(LetterCase.lower)(md5Of(format("%s:%s:%s", method, uri, toHexString!(LetterCase.lower)(md5Of(ebody)).dup))).dup;
270 	}
271 
272 	static if (is(U == string)) auto uri = URL(url).pathString;
273 	else auto uri = url.pathString;
274 
275 	auto dig = appender!string();
276 	dig ~= "Digest ";
277 	dig ~= `username="`; dig ~= username; dig ~= `", `;
278 	dig ~= `realm="`; dig ~= auth.realm; dig ~= `", `;
279 	dig ~= `nonce="`; dig ~= auth.nonce; dig ~= `", `;
280 	dig ~= `uri="`; dig ~= uri; dig ~= `", `;
281 	if (auth.opaque.length) { dig ~= `opaque="`; dig ~= auth.opaque; dig ~= `", `; }
282 
283 	//choose one of provided qop
284 	DigestAuthParams.Qop qop;
285 	if ((auth.qop & DigestAuthParams.Qop.auth) == DigestAuthParams.Qop.auth) qop = DigestAuthParams.Qop.auth;
286 	else if ((auth.qop & DigestAuthParams.Qop.auth_int) == DigestAuthParams.Qop.auth_int) qop = DigestAuthParams.Qop.auth_int;
287 
288 	if (qop != DigestAuthParams.Qop.none) {
289 		assert(cnonce !is null, "cnonce is required");
290 		assert(nc != 0, "nc is required");
291 
292 		dig ~= `qop="`; dig ~= qop == DigestAuthParams.Qop.auth ? "auth" : "auth-int"; dig ~= `", `;
293 		dig ~= `cnonce="`; dig ~= cnonce; dig ~= `", `;
294 		dig ~= `nc="`; dig.formattedWrite("%08x", nc); dig ~= `", `;
295 	}
296 
297 	auto ha1 = auth.algorithm == DigestAuthParams.Algorithm.md5_sess
298 		? getHA1(username, password, auth.realm, auth.nonce, cnonce)
299 		: getHA1(username, password, auth.realm);
300 
301 	auto ha2 = qop != DigestAuthParams.Qop.auth_int
302 		? getHA2(method, uri)
303 		: getHA2(method, uri, entityBody);
304 
305 	auto resp = qop == DigestAuthParams.Qop.none
306 		? toHexString!(LetterCase.lower)(md5Of(format("%s:%s:%s", ha1, auth.nonce, ha2))).dup
307 		: 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;
308 
309 	dig ~= `response="`; dig ~= resp; dig ~= `"`;
310 
311 	return dig.data;
312 }