Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adapt the web user interface to log in the new server #1080

Merged
merged 27 commits into from
Mar 13, 2024
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
57be84b
Set a cookie with the token after authentication
imobachgs Mar 7, 2024
78ce739
Add an endpoint to check if the user is authenticated
imobachgs Mar 7, 2024
07b69cb
Fix the /authenticate documentation
imobachgs Mar 7, 2024
d1a7727
Add a log in form to the web UI
imobachgs Mar 7, 2024
9ee81a4
Add some props for making core/About more flexible
dgdavid Mar 7, 2024
1b602a6
Allow core/Page to be mounted without sidebar
dgdavid Mar 7, 2024
9da555c
Improve login page
dgdavid Mar 7, 2024
2b9364e
Please linters and improve type checking
dgdavid Mar 8, 2024
032bbfc
Add an HTTPClient for the agama-server API
imobachgs Mar 8, 2024
54bf258
Adapt the L10nClient to the HTTPClient
imobachgs Mar 8, 2024
805c34a
Adapt the client factories to use the HTTPClient
imobachgs Mar 8, 2024
bbb00e7
Reorganize and extend the authentication API
imobachgs Mar 8, 2024
78ec08f
Add a L10nConfigChanged event
imobachgs Mar 8, 2024
df7cdd8
Postpone the installer client initialization
imobachgs Mar 8, 2024
df04f5b
Proxy /api/ws to Agama WebSocket in webpack
imobachgs Mar 8, 2024
306f370
Returns null instead of "" when no product is selected
imobachgs Mar 8, 2024
e3bf581
Fix L10nClient#getConfig method
imobachgs Mar 8, 2024
d3c4c1d
Add a ProductClient that works with the HTTP API
imobachgs Mar 8, 2024
90dd55c
Make it possible to reach the product selection page
imobachgs Mar 8, 2024
aea817f
Allow setting the keymap in the local installation
imobachgs Mar 11, 2024
7719755
Adapt InstallerL10n provider to use {Set,Get}UIKeymap functions
imobachgs Mar 11, 2024
b6304a4
Merge client/software imports
imobachgs Mar 11, 2024
995bd7a
Merge branch 'architecture_2024' into login
imobachgs Mar 11, 2024
fbde394
Apply suggestions from code review
imobachgs Mar 12, 2024
06271eb
Be consistent when using the URL constructor
imobachgs Mar 12, 2024
6a402e6
Apply suggestions from code review
imobachgs Mar 13, 2024
6bf968d
Updates from code review
imobachgs Mar 13, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions rust/WEB-SERVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,10 @@ $ curl http://localhost:3000/ping
### Authentication

The web server uses a bearer token for HTTP authentication. You can get the token by providing your
password to the `/authenticate` endpoint.
password to the `/auth` endpoint.

```
$ curl http://localhost:3000/authenticate \
$ curl http://localhost:3000/auth \
imobachgs marked this conversation as resolved.
Show resolved Hide resolved
-H "Content-Type: application/json" \
-d '{"password": "your-password"}'
{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3MDg1MTA5MzB9.3HmKAC5u4H_FigMqEa9e74OFAq40UldjlaExrOGqE0U"}⏎
Expand Down
9 changes: 9 additions & 0 deletions rust/agama-locale-data/src/locale.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ pub struct KeymapId {
pub variant: Option<String>,
}

impl Default for KeymapId {
fn default() -> Self {
Self {
layout: "us".to_string(),
variant: None,
}
}
}

#[derive(Error, Debug, PartialEq)]
#[error("Invalid keymap ID: {0}")]
pub struct InvalidKeymap(String);
Expand Down
33 changes: 29 additions & 4 deletions rust/agama-server/src/l10n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@ pub mod web;
use crate::error::Error;
use agama_locale_data::{KeymapId, LocaleCode};
use anyhow::Context;
pub use keyboard::Keymap;
use keyboard::KeymapsDatabase;
pub use locale::LocaleEntry;
use locale::LocalesDatabase;
use std::process::Command;
pub use timezone::TimezoneEntry;
use regex::Regex;
use std::{io, process::Command};
use timezone::TimezonesDatabase;
use zbus::{dbus_interface, Connection};

pub use keyboard::Keymap;
pub use locale::LocaleEntry;
pub use timezone::TimezoneEntry;
pub use web::LocaleConfig;

pub struct Locale {
timezone: String,
timezones_db: TimezonesDatabase,
Expand All @@ -24,6 +27,7 @@ pub struct Locale {
keymap: KeymapId,
keymaps_db: KeymapsDatabase,
ui_locale: LocaleCode,
pub ui_keymap: KeymapId,
}

#[dbus_interface(name = "org.opensuse.Agama1.Locale")]
Expand Down Expand Up @@ -211,6 +215,8 @@ impl Locale {
let mut keymaps_db = KeymapsDatabase::new();
keymaps_db.read()?;

let ui_keymap = Self::x11_keymap().unwrap_or("us".to_string());

let locale = Self {
keymap: "us".parse().unwrap(),
timezone: default_timezone,
Expand All @@ -219,6 +225,7 @@ impl Locale {
timezones_db,
keymaps_db,
ui_locale: ui_locale.clone(),
ui_keymap: ui_keymap.parse().unwrap_or_default(),
};

Ok(locale)
Expand All @@ -230,6 +237,24 @@ impl Locale {
self.ui_locale = locale.clone();
Ok(())
}

fn x11_keymap() -> Result<String, io::Error> {
let output = Command::new("setxkbmap")
.arg("-query")
.env("DISPLAY", ":0")
.output()?;
let output = String::from_utf8(output.stdout)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?;

let keymap_regexp = Regex::new(r"(?m)^layout: (.+)$").unwrap();
let captures = keymap_regexp.captures(&output);
let keymap = captures
.and_then(|c| c.get(1).map(|e| e.as_str()))
.unwrap_or("us")
.to_string();

Ok(keymap)
}
}

pub async fn export_dbus_objects(
Expand Down
32 changes: 30 additions & 2 deletions rust/agama-server/src/l10n/web.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ use axum::{
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::sync::{Arc, RwLock};
use std::{
process::Command,
sync::{Arc, RwLock},
};
use thiserror::Error;

#[derive(Error, Debug)]
Expand All @@ -29,6 +32,8 @@ pub enum LocaleError {
InvalidKeymap(#[from] InvalidKeymap),
#[error("Cannot translate: {0}")]
OtherError(#[from] Error),
#[error("Cannot change the local keymap: {0}")]
CouldNotSetKeymap(#[from] std::io::Error),
}

impl IntoResponse for LocaleError {
Expand Down Expand Up @@ -74,7 +79,7 @@ async fn locales(State(state): State<LocaleState>) -> Json<Vec<LocaleEntry>> {
Json(locales)
}

#[derive(Serialize, Deserialize, utoipa::ToSchema)]
#[derive(Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
pub struct LocaleConfig {
/// Locales to install in the target system
locales: Option<Vec<String>>,
Expand All @@ -84,6 +89,8 @@ pub struct LocaleConfig {
timezone: Option<String>,
/// User-interface locale. It is actually not related to the `locales` property.
ui_locale: Option<String>,
/// User-interface locale. It is relevant only on local installations.
ui_keymap: Option<String>,
}

#[utoipa::path(get, path = "/l10n/timezones", responses(
Expand All @@ -104,6 +111,8 @@ async fn keymaps(State(state): State<LocaleState>) -> Json<Vec<Keymap>> {
Json(keymaps)
}

// TODO: update all or nothing
// TODO: send only the attributes that have changed
#[utoipa::path(put, path = "/l10n/config", responses(
(status = 200, description = "Set the locale configuration", body = LocaleConfig)
))]
Expand All @@ -112,6 +121,7 @@ async fn set_config(
Json(value): Json<LocaleConfig>,
) -> Result<Json<()>, LocaleError> {
let mut data = state.locale.write().unwrap();
let mut changes = LocaleConfig::default();

if let Some(locales) = &value.locales {
for loc in locales {
Expand All @@ -120,17 +130,20 @@ async fn set_config(
}
}
data.locales = locales.clone();
changes.locales = Some(data.locales.clone());
}

if let Some(timezone) = &value.timezone {
if !data.timezones_db.exists(timezone) {
return Err(LocaleError::UnknownTimezone(timezone.to_string()));
}
data.timezone = timezone.to_owned();
changes.timezone = Some(data.timezone.clone());
}

if let Some(keymap_id) = &value.keymap {
data.keymap = keymap_id.parse()?;
changes.keymap = Some(keymap_id.clone());
}

if let Some(ui_locale) = &value.ui_locale {
Expand All @@ -141,11 +154,25 @@ async fn set_config(

helpers::set_service_locale(&locale);
data.translate(&locale)?;
changes.ui_locale = Some(locale.to_string());
_ = state.events.send(Event::LocaleChanged {
locale: locale.to_string(),
});
}

if let Some(ui_keymap) = &value.ui_keymap {
data.ui_keymap = ui_keymap.parse()?;
Command::new("/usr/bin/localectl")
.args(["set-x11-keymap", &ui_keymap])
.output()?;
Command::new("/usr/bin/setxkbmap")
.arg(&ui_keymap)
.env("DISPLAY", ":0")
.output()?;
}

_ = state.events.send(Event::L10nConfigChanged(changes));

Ok(Json(()))
}

Expand All @@ -159,6 +186,7 @@ async fn get_config(State(state): State<LocaleState>) -> Json<LocaleConfig> {
keymap: Some(data.keymap()),
timezone: Some(data.timezone().to_string()),
ui_locale: Some(data.ui_locale().to_string()),
ui_keymap: Some(data.ui_keymap.to_string()),
})
}

Expand Down
7 changes: 6 additions & 1 deletion rust/agama-server/src/software/web.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,10 +220,15 @@ async fn get_config(
State(state): State<SoftwareState<'_>>,
) -> Result<Json<SoftwareConfig>, SoftwareError> {
let product = state.product.product().await?;
let product = if product.is_empty() {
None
} else {
Some(product)
};
let patterns = state.software.user_selected_patterns().await?;
let config = SoftwareConfig {
patterns: Some(patterns),
product: Some(product),
product: product,
};
Ok(Json(config))
}
Expand Down
22 changes: 17 additions & 5 deletions rust/agama-server/src/web/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use axum::{
Json, RequestPartsExt,
};
use axum_extra::{
headers::{authorization::Bearer, Authorization},
headers::{self, authorization::Bearer},
TypedHeader,
};
use chrono::{Duration, Utc};
Expand Down Expand Up @@ -67,13 +67,25 @@ impl FromRequestParts<ServiceState> for TokenClaims {
parts: &mut request::Parts,
state: &ServiceState,
) -> Result<Self, Self::Rejection> {
let TypedHeader(Authorization(bearer)) = parts
.extract::<TypedHeader<Authorization<Bearer>>>()
let token = match parts
.extract::<TypedHeader<headers::Authorization<Bearer>>>()
.await
.map_err(|_| AuthError::MissingToken)?;
{
Ok(TypedHeader(headers::Authorization(bearer))) => bearer.token().to_owned(),
Err(_) => {
let cookie = parts
.extract::<TypedHeader<headers::Cookie>>()
.await
.map_err(|_| AuthError::MissingToken)?;
cookie
.get("token")
.ok_or(AuthError::MissingToken)?
.to_owned()
}
};

let decoding = DecodingKey::from_secret(state.config.jwt_secret.as_ref());
let token_data = jsonwebtoken::decode(bearer.token(), &decoding, &Validation::default())?;
let token_data = jsonwebtoken::decode(&token, &decoding, &Validation::default())?;

Ok(token_data.claims)
}
Expand Down
2 changes: 2 additions & 0 deletions rust/agama-server/src/web/event.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::l10n::web::LocaleConfig;
use agama_lib::{progress::Progress, software::SelectedBy};
use serde::Serialize;
use std::collections::HashMap;
Expand All @@ -6,6 +7,7 @@ use tokio::sync::broadcast::{Receiver, Sender};
#[derive(Clone, Serialize)]
#[serde(tag = "type")]
pub enum Event {
L10nConfigChanged(LocaleConfig),
LocaleChanged { locale: String },
Progress(Progress),
ProductChanged { id: String },
Expand Down
48 changes: 42 additions & 6 deletions rust/agama-server/src/web/http.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
//! Implements the handlers for the HTTP-based API.

use super::{
auth::{generate_token, AuthError},
auth::{generate_token, AuthError, TokenClaims},
state::ServiceState,
};
use axum::{extract::State, Json};
use axum::{
extract::State,
http::{header::SET_COOKIE, HeaderMap},
response::IntoResponse,
Json,
};
use pam::Client;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
Expand Down Expand Up @@ -36,19 +41,50 @@ pub struct LoginRequest {
pub password: String,
}

#[utoipa::path(get, path = "/authenticate", responses(
#[utoipa::path(post, path = "/auth", responses(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall we use "/api/auth" here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, yes.

(status = 200, description = "The user have been successfully authenticated", body = AuthResponse)
))]
pub async fn authenticate(
pub async fn login(
State(state): State<ServiceState>,
Json(login): Json<LoginRequest>,
) -> Result<Json<AuthResponse>, AuthError> {
) -> Result<impl IntoResponse, AuthError> {
let mut pam_client = Client::with_password("agama")?;
pam_client
.conversation_mut()
.set_credentials("root", login.password);
pam_client.authenticate()?;

let token = generate_token(&state.config.jwt_secret);
Ok(Json(AuthResponse { token }))
let content = Json(AuthResponse {
token: token.to_owned(),
});

let mut headers = HeaderMap::new();
let cookie = format!("token={}; HttpOnly", &token);
headers.insert(
SET_COOKIE,
cookie.parse().expect("could not build a valid cookie"),
);

Ok((headers, content))
}

#[utoipa::path(delete, path = "/auth", responses(
(status = 204, description = "The user have been logged out")
imobachgs marked this conversation as resolved.
Show resolved Hide resolved
))]
pub async fn logout(_claims: TokenClaims) -> Result<impl IntoResponse, AuthError> {
let mut headers = HeaderMap::new();
let cookie = "token=deleted; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:00 GMT".to_string();
headers.insert(
SET_COOKIE,
cookie.parse().expect("could not build a valid cookie"),
);
Ok(headers)
}

#[utoipa::path(get, path = "/auth", responses(
(status = 200, description = "Check whether the user is authenticated")
))]
pub async fn session(_claims: TokenClaims) -> Result<(), AuthError> {
Ok(())
}
5 changes: 3 additions & 2 deletions rust/agama-server/src/web/service.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use super::http::{login, logout, session};
use super::{auth::TokenClaims, config::ServiceConfig, state::ServiceState, EventsSender};
use axum::{
extract::Request,
Expand All @@ -19,7 +20,7 @@ use tower_http::{compression::CompressionLayer, services::ServeDir, trace::Trace
///
/// * A static assets directory (`public_dir`).
/// * A websocket at the `/ws` path.
/// * An authentication endpoint at `/authenticate`.
/// * An authentication endpoint at `/auth`.
/// * A 'ping' endpoint at '/ping'.
/// * A number of authenticated services that are added using the `add_service` function.
pub struct MainServiceBuilder {
Expand Down Expand Up @@ -81,7 +82,7 @@ impl MainServiceBuilder {
state.clone(),
))
.route("/ping", get(super::http::ping))
.route("/authenticate", post(super::http::authenticate));
.route("/auth", post(login).get(session).delete(logout));

let serve = ServeDir::new(self.public_dir);
Router::new()
Expand Down
Loading
Loading