Skip to content

Commit

Permalink
Adapt the web user interface to log in the new server (#1080)
Browse files Browse the repository at this point in the history
This pull request aims to adapt the web UI to start using the new
`agama-server`.

The user should be able to:

* Log in the new `agama-server` (using the root password).
* Find the list of existing products and allow the user to select one.

To make it possible to reach the product selection list, several things
need to be adapted.

## Screenshots

Although texts still need a review, you can get an idea of how it looks
with the following screenshots.

![Screenshot 2024-03-08 at 06-18-09
Agama](https://github.com/openSUSE/agama/assets/15836/1e6d295f-8942-4986-9112-a07ebc6c0624)

![Screenshot 2024-03-08 at 06-17-39
Agama](https://github.com/openSUSE/agama/assets/15836/0bbf02f7-baaa-4d2c-b737-743a4be56054)
  • Loading branch information
imobachgs authored Mar 13, 2024
2 parents 3d97d73 + 6bf968d commit fbb5049
Show file tree
Hide file tree
Showing 38 changed files with 1,130 additions and 299 deletions.
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/api/auth \
-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
52 changes: 45 additions & 7 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,52 @@ pub struct LoginRequest {
pub password: String,
}

#[utoipa::path(get, path = "/authenticate", responses(
(status = 200, description = "The user have been successfully authenticated", body = AuthResponse)
#[utoipa::path(post, path = "/api/auth", responses(
(status = 200, description = "The user has 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 = "/api/auth", responses(
(status = 204, description = "The user has been logged out.")
))]
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)
}

/// Check whether the user is authenticated.
#[utoipa::path(get, path = "/api/auth", responses(
(status = 200, description = "The user is authenticated."),
(status = 400, description = "The user is not 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

0 comments on commit fbb5049

Please sign in to comment.