orca/net/
auth.rs

1//! # Authorization
2//! Authorization for a Reddit client is done by OAuth, which can be done multiple (3) ways. The
3//! possible methods of authorization are Script, Installed App, and Web App. Currently, only
4//! the first two are supported by orca. There are certain use cases for each app type.
5//!
6//! ## Scripts
7//!
8//! Script apps are used when you only want to authorize one user, that you own. This is the app
9//! type used for bots. It's special because it can keep a secret (secrets can be stored on the
10//! with the client). To create a script app, you first have to register it at
11//! [https://www.reddit.com/prefs/apps](https://www.reddit.com/prefs/apps). Make sure you're logged
12//! in as the user you want the script to authorize as when you register the app. At the bottom of
13//! the page, click "Create New App", and fill in the name, select script type, enter a short
14//! description (that will only be seen by you), leave the about url empty, and set the redirect uri
15//! to `https://www.example.com`. (We do this because this field is only necessary for installed
16//! apps but is required to be filled in anyway.)
17//!
18//! Once you create the app, a box should pop up that has the name of your app, and then shortly
19//! below it a string of random characters. This is the id of the script. Then lower in the
20//! properties there should be a field called "secret" with another long string of characters. That
21//! is your app's secret.
22//!
23//! Once you have the id and secret, you can instantiate an `OAuthApp::Script` enum with the id and
24//! secret of the script and the username and password of the user that registered the app, and
25//! pass it into the `authorize` function of an `App` instance.
26//!
27//! ## Installed Apps
28//!
29//! Installed apps are used when you want your program to be able to be authorized as any user that
30//! is using it. They are unable to keep a secret, so it is more complicated to authorize them.
31//! An installed app has no secret id. Instead, it requires that the user visits a url to reddit.com
32//! containing info for authorization. After authorizing, reddit.com will redirect the web browser
33//! to the redirect uri specified during the app registration, with the tokens requested as
34//! parameters. The redirect uri is usually the loopback address with a custom port, and the app
35//! starts an HTTP server to recieve that request and the tokens included.
36//!
37//! Most of this work is implemented for you by orca. At the moment, there is some lacking in
38//! customizability, but that will hopefully change in the future. Currently, orca opens the
39//! reddit.com in the default browser using the `open` crate, and the redirect uri must always be
40//! 127.0.0.1:7878.
41//!
42//! To create an installed app, the process at first is similar to Script app types. Visit
43//! [https://www.reddit.com/prefs/apps](https://www.reddit.com/prefs/apps), and create a new app,
44//! this time with the installed type. Fill in the name, set it to installed app, fill in a short
45//! description (this time it's visible by anyone using your app), enter an about url if you want,
46//! and set the redirect uri to exactly `http://127.0.0.1:7878` (hopefully this will be customizable
47//! in the future).
48//!
49//! When you create this app, the id of the app will be shorly below the name in the box that comes
50//! upp. Now in you application code, create an `OAuthApp::InstalledApp` with the id of you app and
51//! the redirect uri exactly as you entered it when you registered the app. When you call the
52//! `authorize` function with this as a parameter, it will open a web browser with either a reddit
53//! login prompt, or if you are already logged in, a request for permission for your app. Once you
54//! click allow, the page should redirect to a simple display of the words `Authorization successful`.
55//! Hopefully this too will be customizable one day.
56//!
57//! Installed apps, unlike scripts, require periodic reauthorization, or will expire without the
58//! possibility of refreshing if a permanent duration wasn't requested. This should be done
59//! automatically by the `net::Connection` instance.
60
61use rand::{self, Rng};
62use std;
63use std::cell::{Cell, RefCell};
64use std::collections::HashMap;
65use std::sync::{Arc, Mutex};
66use std::thread;
67use std::time::{Duration, Instant};
68
69use base64;
70use failure::Error;
71use futures::future::ok;
72use futures::sync::oneshot::{self, Sender};
73use futures::Future;
74use hyper::header::{self, HeaderValue};
75use hyper::server::Server;
76use hyper::service::{MakeService, Service};
77use hyper::{Body, Error as HyperError, Method, Request, Response};
78use open;
79use url::{self, Url};
80
81use errors::RedditError;
82use net::body_from_map;
83use net::Connection;
84
85/// Function type that is passed into OAuthApp::InstalledApp to generate response from code retrieval.
86pub type ResponseGenFn = (Fn(&Result<String, InstalledAppError>) -> Response<Body>) + Send + Sync;
87
88type CodeSender = Arc<Mutex<Option<Sender<Result<String, InstalledAppError>>>>>;
89
90/// Enum representing OAuth information that has been aquired from authorization. This should only be
91/// used internally within orca.
92#[derive(Debug, Clone)]
93pub enum OAuth {
94	/// Script app type
95	Script {
96		/// Id of the script
97		id: String,
98		/// Secret of the script
99		secret: String,
100		/// Username of the script user
101		username: String,
102		/// Password of the script user
103		password: String,
104		/// Token retrieved from script authorization
105		token: String,
106	},
107	/// Installed app type
108	InstalledApp {
109		/// Id of the installed app
110		id: String,
111		/// Redirect url of the installed app
112		redirect: String,
113		/// Token currently in use
114		token: RefCell<String>,
115		/// The refresh token (to be used to retrieve a new token once the current one expires).
116		/// Not present if temporary authorization was requested
117		refresh_token: RefCell<Option<String>>,
118		/// Instant when the current token expires
119		expire_instant: Cell<Option<Instant>>,
120	},
121}
122
123impl OAuth {
124	/// Refreshes the token (only necessary for installed app types)
125	pub fn refresh(&self, conn: &Connection) -> Result<(), Error> {
126		match *self {
127			OAuth::Script { .. } => Ok(()),
128			OAuth::InstalledApp {
129				ref id,
130				redirect: ref _redirect,
131				ref token,
132				ref refresh_token,
133				ref expire_instant,
134			} => {
135				let old_refresh_token = if let Some(ref refresh_token) = *refresh_token.borrow() { refresh_token.clone() } else { return Err(RedditError::AuthError.into()) };
136				// Get the access token with the new code we just got
137				let mut params: HashMap<&str, &str> = HashMap::new();
138				params.insert("grant_type", "refresh_token");
139				params.insert("refresh_token", &old_refresh_token);
140
141				// Request for the access token
142				let mut tokenreq = Request::builder().method(Method::POST).uri("https://www.reddit.com/api/v1/access_token/.json").body(body_from_map(&params)).unwrap();
143				// httpS is important
144				tokenreq.headers_mut().insert(header::AUTHORIZATION, HeaderValue::from_str(&format!("Basic {}", { base64::encode(&format!("{}:", id)) })).unwrap());
145
146				// Send the request and get the access token as a response
147				let response = conn.run_request(tokenreq)?;
148
149				if let (Some(expires_in), Some(new_token), Some(scope)) = (response.get("expires_in"), response.get("access_token"), response.get("scope")) {
150					let expires_in = expires_in.as_u64().unwrap();
151					let new_token = new_token.as_str().unwrap();
152					let _scope = scope.as_str().unwrap();
153					*token.borrow_mut() = new_token.to_string();
154					expire_instant.set(Some(Instant::now() + Duration::new(expires_in.to_string().parse::<u64>().unwrap(), 0)));
155
156					Ok(())
157				} else {
158					Err(Error::from(RedditError::AuthError))
159				}
160			}
161		}
162	}
163
164	/// Authorize the app as a script
165	/// # Arguments
166	/// * `conn` - A refernce to the connection to authorize
167	/// * `id` - The app id registered on Reddit
168	/// * `secret` - The app secret registered on Reddit
169	/// * `username` - The username of the user to authorize as
170	/// * `password` - The password of the user to authorize as
171	pub fn create_script(conn: &Connection, id: &str, secret: &str, username: &str, password: &str) -> Result<OAuth, Error> {
172		// authorization paramaters to request
173		let mut params: HashMap<&str, &str> = HashMap::new();
174		params.insert("grant_type", "password");
175		params.insert("username", &username);
176		params.insert("password", &password);
177
178		// Request for the bearer token
179		let mut tokenreq = Request::builder().method(Method::POST).uri("https://ssl.reddit.com/api/v1/access_token/.json").body(body_from_map(&params)).unwrap();
180		// httpS is important
181		tokenreq.headers_mut().insert(header::AUTHORIZATION, HeaderValue::from_str(&format!("Basic {}", { base64::encode(&format!("{}:{}", id, secret)) })).unwrap());
182
183		// Send the request and get the bearer token as a response
184		let response = conn.run_request(tokenreq)?;
185
186		if let Some(token) = response.get("access_token") {
187			let token = token.as_str().unwrap().to_string();
188			Ok(OAuth::Script {
189				id: id.to_string(),
190				secret: secret.to_string(),
191				username: username.to_string(),
192				password: password.to_string(),
193				token,
194			})
195		} else {
196			Err(RedditError::AuthError.into())
197		}
198	}
199
200	/// Authorize the app as an installed app
201	/// # Arguments
202	/// * `conn` - A reference to the connection to authorize
203	/// * `id` - The app id registered on Reddit
204	/// * `redirect` - The app redirect URI registered on Reddit
205	/// * `response_gen` - An optional function that generates a hyper Response to give to the user
206	/// based on the result of the authorization attempt. The signature is `(Result<String, InstalledAppError) -> Result<Response, Response>`.
207	/// The result passed in is either Ok with the code recieved, or Err with the error that occurred.
208	/// The value returned should usually be an Ok(Response), but you can return Err(Response) to indicate
209	/// that an error occurred within the function.
210	/// * `scopes` - A reference to a Scopes instance representing the capabilites you are requesting
211	/// as an installed app.
212	pub fn create_installed_app<I: Into<Option<Arc<ResponseGenFn>>>>(conn: &Connection, id: &str, redirect: &str, response_gen: I, scopes: &Scopes) -> Result<OAuth, Error> {
213		let response_gen = response_gen.into();
214		// Random state string to identify this authorization instance
215		let state = rand::thread_rng().gen_ascii_chars().take(16).collect::<String>();
216
217		let scopes = &scopes.to_string();
218		let browser_uri = format!(
219			"https://www.reddit.com/api/v1/authorize?client_id={}&response_type=code&\
220			 state={}&redirect_uri={}&duration=permanent&scope={}",
221			id, state, redirect, scopes
222		);
223
224		let state_rc = Arc::new(state);
225
226		// Open the auth url in the browser so the user can authenticate the app
227		thread::spawn(move || {
228			open::that(browser_uri).expect("Failed to open browser");
229		});
230
231		// A oneshot future channel that the hyper server has access to to send the code back
232		// to this thread.
233		let (code_sender, code_reciever) = oneshot::channel::<Result<String, InstalledAppError>>();
234
235		// Convert the redirect url into something parseable by the HTTP server
236		let redirect_url = Url::parse(&redirect)?;
237		let main_redirect = format!("{}:{}", redirect_url.host_str().unwrap_or("127.0.0.1"), redirect_url.port().unwrap_or(7878).to_string());
238
239		// Set the default response generator if necessary
240		let response_gen = if let Some(ref response_gen) = response_gen {
241			Arc::clone(response_gen)
242		} else {
243			Arc::new(|res: &Result<String, InstalledAppError>| -> Response<Body> {
244				match res {
245					Ok(_) => Response::new("Successfully got the code".into()),
246					Err(e) => Response::new(format!("{}", e).into()),
247				}
248			})
249		};
250
251		// Create a server with the instance of a NewInstalledAppService struct with the
252		// responses given, the oneshot sender and the generated state string
253		let server = Server::bind(&main_redirect.as_str().parse()?).serve(MakeInstalledAppService {
254			code_sender: Arc::new(Mutex::new(Some(code_sender))),
255			state: Arc::clone(&state_rc),
256			response_gen: Arc::clone(&response_gen),
257		});
258
259		// Create a code value that is optional but should be set eventually
260		let code: Arc<Mutex<Result<String, InstalledAppError>>> = Arc::new(Mutex::new(Err(InstalledAppError::NeverRecieved)));
261		let code_clone = Arc::clone(&code);
262
263		// When the code_reciever oneshot resolves, set the new_code value.
264		let finish = code_reciever.then(move |new_code| {
265			let code = code_clone;
266			if let Ok(new_code) = new_code {
267				match new_code {
268					Ok(new_code) => {
269						*code.lock().unwrap() = Ok(new_code);
270						Ok(())
271					}
272					Err(e) => {
273						*code.lock().unwrap() = Err(e);
274						Err(())
275					}
276				}
277			} else {
278				Err(())
279			}
280		});
281
282		let graceful = server.with_graceful_shutdown(finish).map_err(|e| eprintln!("Server failed: {}", e));
283
284		// Run the server until the code future oneshot resolves and has set the code variable.
285		hyper::rt::run(graceful);
286
287		// Make sure we got the code. Return an error if we didn't.
288		let code = match *code.lock().unwrap() {
289			Ok(ref new_code) => new_code.clone(),
290			Err(ref e) => return Err(e.clone().into()),
291		};
292
293		// Get the access token with the new code we just got
294		let mut params: HashMap<&str, &str> = HashMap::new();
295		params.insert("grant_type", "authorization_code");
296		params.insert("code", &code);
297		params.insert("redirect_uri", &redirect);
298
299		// Request for the access token
300		let mut tokenreq = Request::builder().method(Method::POST).uri("https://ssl.reddit.com/api/v1/access_token/.json").body(body_from_map(&params)).unwrap();
301		// httpS is important
302		tokenreq.headers_mut().insert(header::AUTHORIZATION, HeaderValue::from_str(&format!("Basic {}", base64::encode(&format!("{}:", id)))).unwrap());
303
304		// Send the request and get the access token as a response
305		let response = conn.run_request(tokenreq)?;
306
307		if let (Some(expires_in), Some(token), Some(refresh_token), Some(scope)) = (response.get("expires_in"), response.get("access_token"), response.get("refresh_token"), response.get("scope")) {
308			let expires_in = expires_in.as_u64().unwrap();
309			let token = token.as_str().unwrap();
310			let refresh_token = refresh_token.as_str().unwrap();
311			let _scope = scope.as_str().unwrap();
312			Ok(OAuth::InstalledApp {
313				id: id.to_string(),
314				redirect: redirect.to_string(),
315				token: RefCell::new(token.to_string()),
316				refresh_token: RefCell::new(Some(refresh_token.to_string())),
317				expire_instant: Cell::new(Some(Instant::now() + Duration::new(expires_in.to_string().parse::<u64>().unwrap(), 0))),
318			})
319		} else {
320			Err(Error::from(RedditError::AuthError))
321		}
322	}
323}
324
325/// A struct representing scopes that an installed app can request permission for.
326/// To use, create an instance of the struct and set the fields you want to use to true.
327///
328/// Note: In the field documentation, "the user" refers to the currently authorized user
329pub struct Scopes {
330	/// See detailed info about the user
331	pub identity: bool,
332	/// Edit posts of the user
333	pub edit: bool,
334	/// Flair posts of the user
335	pub flair: bool,
336	/// Unknown
337	pub history: bool,
338	/// Unknown
339	pub modconfig: bool,
340	/// Unknown
341	pub modflair: bool,
342	/// Unknown
343	pub modlog: bool,
344	/// Unknown
345	pub modposts: bool,
346	/// Unknown
347	pub modwiki: bool,
348	/// Unknown
349	pub mysubreddits: bool,
350	/// Unknown
351	pub privatemessages: bool,
352	/// Unknown
353	pub read: bool,
354	/// Report posts on behalf of the user
355	pub report: bool,
356	/// Save posts to the user's account
357	pub save: bool,
358	/// Submit posts on behalf of the user
359	pub submit: bool,
360	/// Unknown
361	pub subscribe: bool,
362	/// Vote on things on behalf of the user
363	pub vote: bool,
364	/// Unknown
365	pub wikiedit: bool,
366	/// Unknown
367	pub wikiread: bool,
368	/// Unknown
369	pub account: bool,
370}
371
372impl Scopes {
373	/// Create a scopes instance with no permissions requested
374	pub fn empty() -> Scopes {
375		Scopes {
376			identity: false,
377			edit: false,
378			flair: false,
379			history: false,
380			modconfig: false,
381			modflair: false,
382			modlog: false,
383			modposts: false,
384			modwiki: false,
385			mysubreddits: false,
386			privatemessages: false,
387			read: false,
388			report: false,
389			save: false,
390			submit: false,
391			subscribe: false,
392			vote: false,
393			wikiedit: false,
394			wikiread: false,
395			account: false,
396		}
397	}
398
399	/// Create a scopes instance with all permissions requested
400	pub fn all() -> Scopes {
401		Scopes {
402			identity: true,
403			edit: true,
404			flair: true,
405			history: true,
406			modconfig: true,
407			modflair: true,
408			modlog: true,
409			modposts: true,
410			modwiki: true,
411			mysubreddits: true,
412			privatemessages: true,
413			read: true,
414			report: true,
415			save: true,
416			submit: true,
417			subscribe: true,
418			vote: true,
419			wikiedit: true,
420			wikiread: true,
421			account: true,
422		}
423	}
424
425	/// Convert the struct to a string representation to be sent to Reddit
426	fn to_string(&self) -> String {
427		let mut string = String::new();
428		if self.identity {
429			string.push_str("identity");
430		}
431		if self.edit {
432			string.push_str(",edit");
433		}
434		if self.flair {
435			string.push_str(",flair");
436		}
437		if self.history {
438			string.push_str(",history");
439		}
440		if self.modconfig {
441			string.push_str(",modconfig");
442		}
443		if self.modflair {
444			string.push_str(",modflair");
445		}
446		if self.modlog {
447			string.push_str(",modlog");
448		}
449		if self.modposts {
450			string.push_str(",modposts");
451		}
452		if self.modwiki {
453			string.push_str(",modwiki");
454		}
455		if self.mysubreddits {
456			string.push_str(",mysubreddits");
457		}
458		if self.privatemessages {
459			string.push_str(",privatemessages");
460		}
461		if self.read {
462			string.push_str(",read");
463		}
464		if self.report {
465			string.push_str(",report");
466		}
467		if self.save {
468			string.push_str(",save");
469		}
470		if self.submit {
471			string.push_str(",submit");
472		}
473		if self.subscribe {
474			string.push_str(",subscribe");
475		}
476		if self.vote {
477			string.push_str(",vote");
478		}
479		if self.wikiedit {
480			string.push_str(",wikiedit");
481		}
482		if self.wikiread {
483			string.push_str(",wikiread");
484		}
485		if self.account {
486			string.push_str(",account");
487		}
488
489		string
490	}
491}
492
493/// Enum that contains possible errors from a request for the OAuth Installed App type.
494#[derive(Debug, Fail, Clone)]
495pub enum InstalledAppError {
496	/// Got a generic error in the request
497	#[fail(display = "Got an unknown error: {}", msg)]
498	Error {
499		/// The message included in the error
500		msg: String,
501	},
502	/// The state string wasn't present or did not match
503	#[fail(display = "The states did not match")]
504	MismatchedState,
505	/// The code has already been recieved
506	#[fail(display = "A code was already recieved")]
507	AlreadyRecieved,
508	/// No message was ever recieved
509	#[fail(display = "No message was ever recieved")]
510	NeverRecieved,
511}
512
513struct MakeInstalledAppService {
514	code_sender: CodeSender,
515	state: Arc<String>,
516	response_gen: Arc<ResponseGenFn>,
517}
518
519impl<Ctx> MakeService<Ctx> for MakeInstalledAppService {
520	type ReqBody = Body;
521	type ResBody = Body;
522	type Error = hyper::Error;
523	type Service = InstalledAppService;
524	type Future = Box<Future<Item = Self::Service, Error = Self::MakeError> + Send + Sync>;
525	type MakeError = Box<dyn std::error::Error + Send + Sync>;
526
527	fn make_service(&mut self, _ctx: Ctx) -> Self::Future {
528		Box::new(futures::future::ok(InstalledAppService {
529			code_sender: Arc::clone(&self.code_sender),
530			state: Arc::clone(&self.state),
531			response_gen: Arc::clone(&self.response_gen),
532		}))
533	}
534}
535
536// The service that has the code_sender to send the code back to the main thread, the state to verify
537// that this is the right authorization instance, the optional responses, and a tokio Core needed to
538// clone the responses.
539struct InstalledAppService {
540	code_sender: CodeSender,
541	state: Arc<String>,
542	response_gen: Arc<ResponseGenFn>,
543}
544
545impl Service for InstalledAppService {
546	type ReqBody = Body;
547	type ResBody = Body;
548	type Error = HyperError;
549	type Future = Box<Future<Item = Response<Self::ResBody>, Error = Self::Error> + Send>;
550
551	fn call(&mut self, req: Request<Self::ReqBody>) -> Self::Future {
552		// Get the data from the request (the state and the code, or the error) in a HashMap
553		let query_str = req.uri().path_and_query().unwrap().as_str();
554		let query_str = &query_str[2..query_str.len()];
555		let params: HashMap<_, _> = url::form_urlencoded::parse(query_str.as_bytes()).collect();
556
557		// Create a HTTP response based on the result of the code retrieval, the code sender, and the
558		// response generator.
559		fn create_res(gen: &ResponseGenFn, res: &Result<String, InstalledAppError>, sender: &CodeSender) -> <InstalledAppService as Service>::Future {
560			let mut sender = sender.lock().unwrap();
561			let sender = if let Some(sender) = sender.take() {
562				sender
563			} else {
564				return Box::new(ok(gen(&Err(InstalledAppError::AlreadyRecieved))));
565			};
566			let resp = match sender.send(res.clone()) {
567				Ok(_) => gen(&res),
568				Err(_) => gen(&Err(InstalledAppError::AlreadyRecieved)),
569			};
570			Box::new(ok(resp))
571		}
572
573		// If there was an error stop here, returning the error response
574		if params.contains_key("error") {
575			warn!("Got failed authorization. Error was {}", &params["error"]);
576			let err = InstalledAppError::Error { msg: params["error"].to_string() };
577			create_res(&*self.response_gen, &Err(err.clone()), &self.code_sender)
578		} else {
579			// Get the state if it exists
580			let state = if let Some(state) = params.get("state") {
581				state
582			} else {
583				// Return error response if we didn't get the state
584				return create_res(&*self.response_gen, &Err(InstalledAppError::MismatchedState), &self.code_sender);
585			};
586			// Error if the state doesn't match
587			if *state != *self.state {
588				error!("State didn't match. Got state \"{}\", needed state \"{}\"", state, self.state);
589				create_res(&*self.response_gen, &Err(InstalledAppError::MismatchedState), &self.code_sender)
590			} else {
591				// Get the code and send it with the oneshot sender back to the main thread
592				let code = &params["code"];
593				create_res(&*self.response_gen, &Ok(code.clone().into()), &self.code_sender)
594			}
595		}
596	}
597}
598
599// A neat trait I came up with. If you have a RefCell<Option<T>>, then you can call pop() on it and
600// it will take the value out of the RefCell and give it back. If it doesn't exist, then it just returns None.
601trait RefCellExt<T> {
602	fn pop(&self) -> Option<T>;
603}
604
605impl<T: std::fmt::Debug> RefCellExt<T> for RefCell<Option<T>> {
606	fn pop(&self) -> Option<T> {
607		if self.borrow().is_some() {
608			return std::mem::replace(&mut *self.borrow_mut(), None);
609		}
610
611		None
612	}
613}