Skip to content

Commit 49aa4fc

Browse files
committed
Add support for openid for authentication
The implementation is being dogfed with gitlab as the OpenID issuer. In order to work, the issuer needs to: - be discoverable (with `GET /.well-known/openid-configuration`) - have a `userinfo_endpoint` in the openid configuration Authentication is done through white-listing. If there are no whitelist fields, then anyone can create an account, therefore can publish and own crates in the registry. When ktra is built with openid, all the user management endpoints are disabled to avoid tampering through unauthenticated `POST` calls. Also, there is no point storing a password, but as the password interface is strongly coupled with the DbManager trait, for the time being a dummy password is inserted for users. This is deemed not dangerous as no authenticated routes is compiled when the "openid" feature is present A `user_by_login` function has been added to the DbManager trait because the login is now dynamically computed from the OpenId issuer. A `token_by_login` function has been added to the DbManager trait to allow users to only query their existing token through openid instead of always revoking the old ones. An extra endpoint is added `GET /replace_token` to forcefully rotate the token and invalidate the previous one
1 parent fb1c2ac commit 49aa4fc

17 files changed

Lines changed: 911 additions & 18 deletions

.github/workflows/push.yml

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,34 @@ jobs:
6060
context: .
6161
file: ./docker/ktra.Dockerfile
6262
tags: ghcr.io/${{ github.repository_owner }}/ktra:${{ env.TAG }}
63-
push: true
63+
push: true
64+
65+
- name: "`db-redis + openid` build and push"
66+
uses: docker/build-push-action@v2
67+
with:
68+
context: .
69+
file: ./docker/ktra_openid.Dockerfile
70+
tags: ghcr.io/${{ github.repository_owner }}/ktra:db-redis-openid-${{ env.TAG }}
71+
no-cache: true
72+
build-args: |
73+
DB=db-redis
74+
push: true
75+
76+
- name: "`db-mongo + openid` build and push"
77+
uses: docker/build-push-action@v2
78+
with:
79+
context: .
80+
file: ./docker/ktra_openid.Dockerfile
81+
tags: ghcr.io/${{ github.repository_owner }}/ktra:db-mongo-openid-${{ env.TAG }}
82+
no-cache: true
83+
build-args: |
84+
DB=db-mongo
85+
push: true
86+
87+
- name: "`db-sled + openid` build and push"
88+
uses: docker/build-push-action@v2
89+
with:
90+
context: .
91+
file: ./docker/ktra_openid.Dockerfile
92+
tags: ghcr.io/${{ github.repository_owner }}/ktra:openid-${{ env.TAG }}
93+
push: true

.github/workflows/tests.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,33 @@ jobs:
3535
command: check
3636
args: --no-default-features --features=secure-auth,${{ matrix.db_feature }},crates-io-mirroring
3737

38+
check_openid:
39+
name: Check ${{ matrix.db_feature }} with OpenId (${{ matrix.os }})
40+
runs-on: ${{ matrix.os }}
41+
strategy:
42+
fail-fast: false
43+
matrix:
44+
os: [ubuntu-latest]
45+
rust: [stable]
46+
db_feature: [db-sled, db-redis, db-mongo]
47+
steps:
48+
- name: Checkout sources
49+
uses: actions/checkout@v2
50+
51+
- name: Install toolchain
52+
uses: actions-rs/toolchain@v1
53+
with:
54+
profile: minimal
55+
toolchain: ${{ matrix.rust }}
56+
override: true
57+
58+
- name: Run cargo check
59+
uses: actions-rs/cargo@v1
60+
with:
61+
command: check
62+
args: --no-default-features --features=secure-auth,${{ matrix.db_feature }},crates-io-mirroring,openid
63+
64+
3865
test:
3966
name: Test ${{ matrix.db_feature }} (${{ matrix.os }})
4067
runs-on: ${{ matrix.os }}

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ default = ["secure-auth", "db-sled", "crates-io-mirroring"]
1717
secure-auth = ["rand", "rust-argon2"]
1818
crates-io-mirroring = ["reqwest", "tokio-util"]
1919
mirroring-dummy = []
20+
openid = ["openidconnect", "reqwest"]
2021
db-sled = ["sled"]
2122
db-redis = ["redis"]
2223
db-mongo = ["mongodb", "bson"]
@@ -41,7 +42,7 @@ toml = "0.5"
4142
clap = "2.33"
4243
async-trait = "0.1"
4344

44-
reqwest = { version = "0.11", features = ["gzip", "brotli"], optional = true }
45+
reqwest = { version = "0.11", features = ["gzip", "brotli", "json"], optional = true }
4546
tokio-util = { version = "0.6", features = ["io"], optional = true }
4647

4748
rand = { version = "0.8", optional = true }
@@ -52,3 +53,4 @@ redis = { version = "0.19", features = ["tokio-comp"], optional = true }
5253
mongodb = { version = "1.1", optional = true }
5354
bson = { version = "1.1", features = ["u2i"], optional = true }
5455

56+
openidconnect = { version = "2.1.1", optional = true }

docker/ktra.Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,4 @@ VOLUME /crates
3838
VOLUME /crates_io_crates
3939
EXPOSE 8000
4040
ENTRYPOINT [ "./ktra" ]
41-
CMD []
41+
CMD []

docker/ktra_openid.Dockerfile

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
FROM rust:1.59-slim-bullseye as builder
2+
3+
ARG DB="db-sled"
4+
ARG MIRRORING="crates-io-mirroring"
5+
6+
RUN apt-get update &&\
7+
apt-get upgrade -y &&\
8+
apt-get install -y openssl pkg-config libssl-dev
9+
10+
RUN useradd -m rust
11+
RUN mkdir /build && chown rust:rust /build
12+
USER rust
13+
14+
COPY --chown=rust:rust ./src /build/src
15+
COPY --chown=rust:rust ./Cargo.toml /build/
16+
WORKDIR /build
17+
18+
RUN cargo build --release --no-default-features --features=secure-auth,openid,${DB},${MIRRORING}
19+
20+
FROM debian:bullseye-slim
21+
22+
LABEL org.opencontainers.image.source https://github.com/moriturus/ktra
23+
LABEL org.opencontainers.image.documentation https://book.ktra.dev
24+
LABEL org.opencontainers.image.licenses "(Apache-2.0 OR MIT)"
25+
26+
RUN apt-get update &&\
27+
apt-get upgrade -y &&\
28+
apt-get install -y libssl1.1 ca-certificates &&\
29+
apt-get autoremove -y &&\
30+
apt-get clean -y
31+
32+
COPY LICENSE-APACHE ./
33+
COPY LICENSE-MIT ./
34+
35+
COPY --from=builder /build/target/release/ktra ./
36+
37+
VOLUME /crates
38+
VOLUME /crates_io_crates
39+
EXPOSE 8000
40+
ENTRYPOINT [ "./ktra" ]
41+
CMD []

src/config.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,18 @@ impl ServerConfig {
158158
}
159159
}
160160

161+
#[derive(Debug, Clone, Deserialize, Default)]
162+
pub struct OpenIdConfig {
163+
pub(crate) issuer_url: String,
164+
pub(crate) redirect_url: String,
165+
pub(crate) client_id: String,
166+
pub(crate) client_secret: String,
167+
#[serde(default)]
168+
pub(crate) additional_scopes: Vec<String>,
169+
pub(crate) gitlab_authorized_groups: Option<Vec<String>>,
170+
pub(crate) gitlab_authorized_users: Option<Vec<String>>,
171+
}
172+
161173
#[derive(Debug, Clone, Deserialize)]
162174
pub struct Config {
163175
#[serde(default)]
@@ -168,6 +180,8 @@ pub struct Config {
168180
pub index_config: IndexConfig,
169181
#[serde(default)]
170182
pub server_config: ServerConfig,
183+
#[serde(default)]
184+
pub openid_config: OpenIdConfig,
171185
}
172186

173187
impl Default for Config {
@@ -177,6 +191,7 @@ impl Default for Config {
177191
db_config: Default::default(),
178192
index_config: Config::index_config_default(),
179193
server_config: Default::default(),
194+
openid_config: Default::default(),
180195
}
181196
}
182197
}

src/db_manager/mongo_db_manager.rs

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const ENTRIES_KEY: &str = "__ENTRIES__";
2727
const USERS_KEY: &str = "__USERS__";
2828
const PASSWORDS_KEY: &str = "__PASSWORDS__";
2929
const TOKENS_KEY: &str = "__TOKENS__";
30+
const OAUTH_NONCES_KEY: &str = "__OAUTH_NONCES__";
3031

3132
#[derive(Clone, SerializeTrait, DeserializeTrait)]
3233
struct TokenMap {
@@ -228,6 +229,44 @@ impl DbManager for MongoDbManager {
228229
.ok_or_else(|| Error::InvalidToken(token.to_owned()))
229230
}
230231

232+
#[tracing::instrument(skip(self, login))]
233+
async fn token_by_login(&self, login: &str) -> Result<Option<String>, Error> {
234+
match self.user_by_login(login).await {
235+
Ok(user) => {
236+
let collection = self
237+
.client
238+
.database(&self.database_name)
239+
.collection(TOKENS_KEY);
240+
Ok(collection
241+
.find_one(doc! { "id": user.id }, None)
242+
.map_err(Error::Db)
243+
.await?
244+
.and_then(|d| d.get("token").cloned())
245+
.and_then(|b| b.as_str().map(ToString::to_string)))
246+
}
247+
Err(_) => Ok(None),
248+
}
249+
}
250+
251+
#[tracing::instrument(skip(self, name))]
252+
async fn token_by_username(&self, name: &str) -> Result<Option<String>, Error> {
253+
match self.user_by_username(name).await {
254+
Ok(user) => {
255+
let collection = self
256+
.client
257+
.database(&self.database_name)
258+
.collection(TOKENS_KEY);
259+
Ok(collection
260+
.find_one(doc! { "id": user.id }, None)
261+
.map_err(Error::Db)
262+
.await?
263+
.and_then(|d| d.get("token").cloned())
264+
.and_then(|b| b.as_str().map(ToString::to_string)))
265+
}
266+
Err(_) => Ok(None),
267+
}
268+
}
269+
231270
#[tracing::instrument(skip(self, user_id, token))]
232271
async fn set_token(&self, user_id: u32, token: &str) -> Result<(), Error> {
233272
let token = token.to_owned();
@@ -240,19 +279,27 @@ impl DbManager for MongoDbManager {
240279
async fn user_by_username(&self, name: &str) -> Result<User, Error> {
241280
let name = name.to_owned();
242281
let login = format!("{}{}", self.login_prefix, name);
282+
self.user_by_login(&login)
283+
.await
284+
.map_err(|_| Error::InvalidUsername(name.to_string()))
285+
}
286+
287+
#[tracing::instrument(skip(self, login))]
288+
async fn user_by_login(&self, login: &str) -> Result<User, Error> {
289+
let login = login.to_owned();
243290
let collection = self
244291
.client
245292
.database(&self.database_name)
246293
.collection(USERS_KEY);
247294

248295
collection
249-
.find_one(doc! { "login": login }, None)
296+
.find_one(doc! { "login": login.clone() }, None)
250297
.map_err(Error::Db)
251298
.await?
252299
.map(from_document::<User>)
253300
.transpose()
254301
.map_err(Error::BsonDeserialization)?
255-
.ok_or_else(|| Error::InvalidUsername(name))
302+
.ok_or_else(|| Error::InvalidLogin(login))
256303
}
257304

258305
#[tracing::instrument(skip(self, user, password))]
@@ -492,6 +539,42 @@ impl DbManager for MongoDbManager {
492539
Err(Error::multiple(errors))
493540
}
494541
}
542+
543+
#[cfg(feature = "openid")]
544+
async fn store_nonce_by_csrf(
545+
&self,
546+
state: openidconnect::CsrfToken,
547+
nonce: openidconnect::Nonce,
548+
) -> Result<(), Error> {
549+
let collection = self
550+
.client
551+
.database(&self.database_name)
552+
.collection(OAUTH_NONCES_KEY);
553+
let nonces_query_document = doc! {"state": state.secret().to_string() };
554+
555+
self.update_or_insert_one(OAUTH_NONCES_KEY, nonces_query_document, nonce)
556+
.await
557+
}
558+
559+
#[cfg(feature = "openid")]
560+
async fn get_nonce_by_csrf(
561+
&self,
562+
state: openidconnect::CsrfToken,
563+
) -> Result<openidconnect::Nonce, Error> {
564+
let collection = self
565+
.client
566+
.database(&self.database_name)
567+
.collection(OAUTH_NONCES_KEY);
568+
569+
collection
570+
.find_one(doc! { "state": state.secret().to_string() }, None)
571+
.map_err(Error::Db)
572+
.await?
573+
.map(from_document::<openidconnect::Nonce>)
574+
.transpose()
575+
.map_err(Error::BsonDeserialization)?
576+
.ok_or_else(|| Error::InvalidCsrfToken(state.secret().to_string()))
577+
}
495578
}
496579

497580
impl MongoDbManager {

0 commit comments

Comments
 (0)