about summary refs log tree commit diff
path: root/kittybox-rs/src/frontend/login.rs
blob: 9665ce7e30c013a9e736239e2069501bf9de7efd (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
use http_types::Mime;
use log::{debug, error};
use rand::Rng;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::convert::TryInto;
use std::str::FromStr;

use crate::frontend::templates::Template;
use crate::frontend::{FrontendError, IndiewebEndpoints};
use crate::{database::Storage, ApplicationState};
use kittybox_templates::LoginPage;

pub async fn form<S: Storage>(req: Request<ApplicationState<S>>) -> Result {
    let owner = req.url().origin().ascii_serialization() + "/";
    let storage = &req.state().storage;
    let authorization_endpoint = req.state().authorization_endpoint.to_string();
    let token_endpoint = req.state().token_endpoint.to_string();
    let blog_name = storage
        .get_setting("site_name", &owner)
        .await
        .unwrap_or_else(|_| "Kitty Box!".to_string());
    let feeds = storage.get_channels(&owner).await.unwrap_or_default();

    Ok(Response::builder(200)
        .body(
            Template {
                title: "Sign in with IndieAuth",
                blog_name: &blog_name,
                endpoints: IndiewebEndpoints {
                    authorization_endpoint,
                    token_endpoint,
                    webmention: None,
                    microsub: None,
                },
                feeds,
                user: req.session().get("user"),
                content: LoginPage {}.to_string(),
            }
            .to_string(),
        )
        .content_type("text/html; charset=utf-8")
        .build())
}

#[derive(Serialize, Deserialize)]
struct LoginForm {
    url: String,
}

#[derive(Serialize, Deserialize)]
struct IndieAuthClientState {
    /// A random value to protect from CSRF attacks.
    nonce: String,
    /// The user's initial "me" value.
    me: String,
    /// Authorization endpoint used.
    authorization_endpoint: String,
}

#[derive(Serialize, Deserialize)]
struct IndieAuthRequestParams {
    response_type: String,         // can only have "code". TODO make an enum
    client_id: String,             // always a URL. TODO consider making a URL
    redirect_uri: surf::Url,       // callback URI for IndieAuth
    state: String, // CSRF protection, should include randomness and be passed through
    code_challenge: String, // base64-encoded PKCE challenge
    code_challenge_method: String, // usually "S256". TODO make an enum
    scope: Option<String>, // oAuth2 scopes to grant,
    me: surf::Url, // User's entered profile URL
}

/// Handle login requests. Find the IndieAuth authorization endpoint and redirect to it.
pub async fn handler<S: Storage>(mut req: Request<ApplicationState<S>>) -> Result {
    let content_type = req.content_type();
    if content_type.is_none() {
        return Err(FrontendError::with_code(400, "Use the login form, Luke.").into());
    }
    if content_type.unwrap() != Mime::from_str("application/x-www-form-urlencoded").unwrap() {
        return Err(
            FrontendError::with_code(400, "Login form results must be a urlencoded form").into(),
        );
    }

    let form = req.body_form::<LoginForm>().await?; // FIXME check if it returns 400 or 500 on error
    let homepage_uri = surf::Url::parse(&form.url)?;
    let http = &req.state().http_client;

    let mut fetch_response = http.get(&homepage_uri).send().await?;
    if fetch_response.status() != 200 {
        return Err(FrontendError::with_code(
            500,
            "Error fetching your authorization endpoint. Check if your website's okay.",
        )
        .into());
    }

    let mut authorization_endpoint: Option<surf::Url> = None;
    if let Some(links) = fetch_response.header("Link") {
        // NOTE: this is the same Link header parser used in src/micropub/post.rs:459.
        // One should refactor it to a function to use independently and improve later
        for link in links.iter().flat_map(|i| i.as_str().split(',')) {
            debug!("Trying to match {} as authorization_endpoint", link);
            let mut split_link = link.split(';');

            match split_link.next() {
                Some(uri) => {
                    if let Some(uri) = uri.strip_prefix('<').and_then(|uri| uri.strip_suffix('>')) {
                        debug!("uri: {}", uri);
                        for prop in split_link {
                            debug!("prop: {}", prop);
                            let lowercased = prop.to_ascii_lowercase();
                            let trimmed = lowercased.trim();
                            if trimmed == "rel=\"authorization_endpoint\""
                                || trimmed == "rel=authorization_endpoint"
                            {
                                if let Ok(endpoint) = homepage_uri.join(uri) {
                                    debug!(
                                        "Found authorization endpoint {} for user {}",
                                        endpoint,
                                        homepage_uri.as_str()
                                    );
                                    authorization_endpoint = Some(endpoint);
                                    break;
                                }
                            }
                        }
                    }
                }
                None => continue,
            }
        }
    }
    // If the authorization_endpoint is still not found after the Link parsing gauntlet,
    // bring out the big guns and parse HTML to find it.
    if authorization_endpoint.is_none() {
        let body = fetch_response.body_string().await?;
        let pattern =
            easy_scraper::Pattern::new(r#"<link rel="authorization_endpoint" href="{{url}}">"#)
                .expect("Cannot parse the pattern for authorization_endpoint");
        let matches = pattern.matches(&body);
        debug!("Matches for authorization_endpoint in HTML: {:?}", matches);
        if !matches.is_empty() {
            if let Ok(endpoint) = homepage_uri.join(&matches[0]["url"]) {
                debug!(
                    "Found authorization endpoint {} for user {}",
                    endpoint,
                    homepage_uri.as_str()
                );
                authorization_endpoint = Some(endpoint)
            }
        }
    };
    // If even after this the authorization endpoint is still not found, bail out.
    if authorization_endpoint.is_none() {
        error!(
            "Couldn't find authorization_endpoint for {}",
            homepage_uri.as_str()
        );
        return Err(FrontendError::with_code(
            400,
            "Your website doesn't support the IndieAuth protocol.",
        )
        .into());
    }
    let mut authorization_endpoint: surf::Url = authorization_endpoint.unwrap();
    let mut rng = rand::thread_rng();
    let state: String = data_encoding::BASE64URL.encode(
        serde_urlencoded::to_string(IndieAuthClientState {
            nonce: (0..8)
                .map(|_| {
                    let idx = rng.gen_range(0..INDIEAUTH_PKCE_CHARSET.len());
                    INDIEAUTH_PKCE_CHARSET[idx] as char
                })
                .collect(),
            me: homepage_uri.to_string(),
            authorization_endpoint: authorization_endpoint.to_string(),
        })?
        .as_bytes(),
    );
    // PKCE code generation
    let code_verifier: String = (0..128)
        .map(|_| {
            let idx = rng.gen_range(0..INDIEAUTH_PKCE_CHARSET.len());
            INDIEAUTH_PKCE_CHARSET[idx] as char
        })
        .collect();
    let mut hasher = Sha256::new();
    hasher.update(code_verifier.as_bytes());
    let code_challenge: String = data_encoding::BASE64URL.encode(&hasher.finalize());

    authorization_endpoint.set_query(Some(&serde_urlencoded::to_string(
        IndieAuthRequestParams {
            response_type: "code".to_string(),
            client_id: req.url().origin().ascii_serialization(),
            redirect_uri: req.url().join("login/callback")?,
            state: state.clone(),
            code_challenge,
            code_challenge_method: "S256".to_string(),
            scope: Some("profile".to_string()),
            me: homepage_uri,
        },
    )?));

    let cookies = vec![
        format!(
            r#"indieauth_state="{}"; Same-Site: None; Secure; Max-Age: 600"#,
            state
        ),
        format!(
            r#"indieauth_code_verifier="{}"; Same-Site: None; Secure; Max-Age: 600"#,
            code_verifier
        ),
    ];

    let cookie_header = cookies
        .iter()
        .map(|i| -> http_types::headers::HeaderValue { (i as &str).try_into().unwrap() })
        .collect::<Vec<_>>();

    Ok(Response::builder(302)
        .header("Location", authorization_endpoint.to_string())
        .header("Set-Cookie", &*cookie_header)
        .build())
}

const INDIEAUTH_PKCE_CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\
                                        abcdefghijklmnopqrstuvwxyz\
                                        1234567890-._~";

#[derive(Deserialize)]
struct IndieAuthCallbackResponse {
    code: Option<String>,
    error: Option<String>,
    error_description: Option<String>,
    #[allow(dead_code)]
    error_uri: Option<String>,
    // This needs to be further decoded to receive state back and will always be present
    state: String,
}

impl IndieAuthCallbackResponse {
    fn is_successful(&self) -> bool {
        self.code.is_some()
    }
}

#[derive(Serialize, Deserialize)]
struct IndieAuthCodeRedeem {
    grant_type: String,
    code: String,
    client_id: String,
    redirect_uri: String,
    code_verifier: String,
}

#[derive(Serialize, Deserialize)]
struct IndieWebProfile {
    name: Option<String>,
    url: Option<String>,
    email: Option<String>,
    photo: Option<String>,
}

#[derive(Serialize, Deserialize)]
struct IndieAuthResponse {
    me: String,
    scope: Option<String>,
    access_token: Option<String>,
    token_type: Option<String>,
    profile: Option<IndieWebProfile>,
}

/// Handle IndieAuth parameters, fetch the final h-card and redirect the user to the homepage.
pub async fn callback<S: Storage>(mut req: Request<ApplicationState<S>>) -> Result {
    let params: IndieAuthCallbackResponse = req.query()?;
    let http: &surf::Client = &req.state().http_client;
    let origin = req.url().origin().ascii_serialization();

    if req.cookie("indieauth_state").unwrap().value() != params.state {
        return Err(FrontendError::with_code(400, "The state doesn't match. A possible CSRF attack was prevented. Please try again later.").into());
    }
    let state: IndieAuthClientState =
        serde_urlencoded::from_bytes(&data_encoding::BASE64URL.decode(params.state.as_bytes())?)?;

    if !params.is_successful() {
        return Err(FrontendError::with_code(
            400,
            &format!(
                "The authorization endpoint indicated a following error: {:?}: {:?}",
                &params.error, &params.error_description
            ),
        )
        .into());
    }

    let authorization_endpoint = surf::Url::parse(&state.authorization_endpoint).unwrap();
    let mut code_response = http
        .post(authorization_endpoint)
        .body_string(serde_urlencoded::to_string(IndieAuthCodeRedeem {
            grant_type: "authorization_code".to_string(),
            code: params.code.unwrap().to_string(),
            client_id: origin.to_string(),
            redirect_uri: origin + "/login/callback",
            code_verifier: req
                .cookie("indieauth_code_verifier")
                .unwrap()
                .value()
                .to_string(),
        })?)
        .header("Content-Type", "application/x-www-form-urlencoded")
        .header("Accept", "application/json")
        .send()
        .await?;

    if code_response.status() != 200 {
        return Err(FrontendError::with_code(
            code_response.status(),
            &format!(
                "Authorization endpoint returned an error when redeeming the code: {}",
                code_response.body_string().await?
            ),
        )
        .into());
    }

    let json: IndieAuthResponse = code_response.body_json().await?;
    let session = req.session_mut();
    session.insert("user", &json.me)?;

    // TODO redirect to the page user came from
    Ok(Response::builder(302).header("Location", "/").build())
}