Skip to content

Commit

Permalink
Add API Key Authentication (#269)
Browse files Browse the repository at this point in the history
Added API Key authentication. 

Due to a recurring question about authentication of clients I've implemented a Interceptor layer to the tonic server to check all calls for valid api keys. 

example config: 
```
auth.enabled = false --defaults to false
auth.tokens = {
    { client = "test", token = "Sometest" },
    { client = "another client", token = "Some other test" }
}
```
`auth.tokens` is a table of auth keys with their client name. 
There can be a "default" client or a single key for all clients, but this is up to configuration. 
There can be as many client keys as needed. 

In the debug log the client that authenticates is logged. 

### Performance
Performance wise there is no notable difference. 

### Possible future features

* Possibly in the future "expiration_date" can be added to automatically revoke issues, but I didn't think that was needed for a first implementation. 

* Add per client `eval` authorisation
  • Loading branch information
dutchie032 authored Sep 28, 2024
1 parent 3d05d60 commit 36a187d
Show file tree
Hide file tree
Showing 11 changed files with 154 additions and 13 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added `GetClients` to `SrsService`, which retrieves a list of units that are connected to SRS and the frequencies they are connected to.
- Added `SrsConnectEvent` and `SrsDisconnectEvent` events
- Added `GetDrawArgumentValue` API for units, which returns the value for drawing. (useful for "hook down", "doors open" checks)
- Added Authentication Interceptor. This enables authentication on a per client basis.

### Fixed
- Fixed `MarkAddEvent`, `MarkChangeEvent` and `MarkRemoveEvent` position
Expand Down
27 changes: 20 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ time = { version = "0.3", features = ["formatting", "parsing"] }
tokio.workspace = true
tokio-stream.workspace = true
tonic.workspace = true
tonic-middleware = "0.1.4"

[build-dependencies]
walkdir = "2.3"
Expand Down
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@ throughputLimit = 600
-- Whether the integrity check, meant to spot installation issues, is disabled.
integrityCheckDisabled = false

-- Whether or not authentication is required
auth.enabled = false
-- Authentication tokens table with client names and their tokens for split tokens.
auth.tokens = {
-- client => clientName, token => Any token. Advice to use UTF-8 only. Length not limited explicitly
{ client = "SomeClient", token = "SomeToken" },
{ client = "SomeClient2", token = "SomeOtherToken" }
}

-- The default TTS provider to use if a TTS request does not explicitly specify another one.
tts.defaultProvider = "win"

Expand Down Expand Up @@ -217,6 +226,52 @@ In order to develop clients for `DCS-gRPC` you must be familiar with gRPC concep

The gRPC .proto files are available in the `Docs/DCS-gRPC` folder and also available in the Github repo

### Client Authentication

If authentication is enabled on the server you will have to add `X-API-Key` to the metadata/headers.
Below are some example on what it could look like in your code.

#### Examples

<details>
<summary>dotnet / c# </summary>

You can either set the `Metadata` for each request or you can create a `GrpcChannel` with an interceptor that will set the key each time.

For a single request:

```c#
var client = new MissionService.MissionServiceClient(channel);

Metadata metadata = new Metadata()
{
{ "X-API-Key", "<yourKey>" }
};

var response = client.GetScenarioCurrentTime(new GetScenarioCurrentTimeRequest { }, headers: metadata, deadline: DateTime.UtcNow.AddSeconds(2));
```

For all requests on a channel:
```c#
public GrpcChannel CreateChannel(string host, string post, string? apiKey)
{
GrpcChannelOptions options = new GrpcChannelOptions();
if (apiKey != null)
{
CallCredentials credentials = CallCredentials.FromInterceptor(async (context, metadata) =>
{
metadata.Add("X-API-Key", apiKey);
});

options.Credentials = ChannelCredentials.Create(ChannelCredentials.Insecure, credentials) ;
}

return GrpcChannel.ForAddress($"http://{host}:{port}", options);
}
```

</details>

## Server Development

The following section is only applicable to people who want to developer the DCS-gRPC server itself.
Expand Down
1 change: 1 addition & 0 deletions lua/DCS-gRPC/grpc-mission.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ if not GRPC then
-- scaffold nested tables to allow direct assignment in config file
tts = { provider = { gcloud = {}, aws = {}, azure = {}, win = {} } },
srs = {},
auth = {}
}
end

Expand Down
1 change: 1 addition & 0 deletions lua/DCS-gRPC/grpc.lua
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ if isMissionEnv then
integrityCheckDisabled = GRPC.integrityCheckDisabled,
tts = GRPC.tts,
srs = GRPC.srs,
auth = GRPC.auth
}))
end

Expand Down
1 change: 1 addition & 0 deletions lua/Hooks/DCS-gRPC.lua
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ local function init()
-- scaffold nested tables to allow direct assignment in config file
tts = { provider = { gcloud = {}, aws = {}, azure = {}, win = {} } },
srs = {},
auth = {}
}
end

Expand Down
39 changes: 39 additions & 0 deletions src/authentication.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
use crate::config::AuthConfig;
use tonic::codegen::http::Request;
use tonic::transport::Body;
use tonic::{async_trait, Status};
use tonic_middleware::RequestInterceptor;

#[derive(Clone)]
pub struct AuthInterceptor {
pub auth_config: AuthConfig,
}

#[async_trait]
impl RequestInterceptor for AuthInterceptor {
async fn intercept(&self, req: Request<Body>) -> Result<Request<Body>, Status> {
if !self.auth_config.enabled {
Ok(req)
} else {
match req.headers().get("X-API-Key").map(|v| v.to_str()) {
Some(Ok(token)) => {
let mut client: Option<&String> = None;
for key in &self.auth_config.tokens {
if key.token == token {
client = Some(&key.client);
break;
}
}

if client.is_some() {
log::debug!("Authenticated client: {}", client.unwrap());
Ok(req)
} else {
Err(Status::unauthenticated("Unauthenticated"))
}
}
_ => Err(Status::unauthenticated("Unauthenticated")),
}
}
}
}
17 changes: 17 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub struct Config {
pub integrity_check_disabled: bool,
pub tts: Option<TtsConfig>,
pub srs: Option<SrsConfig>,
pub auth: Option<AuthConfig>,
}

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
Expand Down Expand Up @@ -87,6 +88,22 @@ pub struct SrsConfig {
pub addr: Option<SocketAddr>,
}

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthConfig {
#[serde(default)]
pub enabled: bool,
pub tokens: Vec<ApiKey>,
}

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ApiKey {
#[serde(default)]
pub client: String,
pub token: String,
}

fn default_host() -> String {
String::from("127.0.0.1")
}
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#![allow(dead_code)]
#![recursion_limit = "256"]

mod authentication;
mod config;
mod fps;
#[cfg(feature = "hot-reload")]
Expand Down
23 changes: 17 additions & 6 deletions src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;

use crate::authentication::AuthInterceptor;
use crate::config::{AuthConfig, Config, SrsConfig, TtsConfig};
use crate::rpc::{HookRpc, MissionRpc, Srs};
use crate::shutdown::{Shutdown, ShutdownHandle};
use crate::srs::SrsClients;
use crate::stats::Stats;
use dcs_module_ipc::IPC;
use futures_util::FutureExt;
use stubs::atmosphere::v0::atmosphere_service_server::AtmosphereServiceServer;
Expand All @@ -25,12 +31,7 @@ use tokio::sync::oneshot::{self, Receiver};
use tokio::sync::{mpsc, Mutex};
use tokio::time::sleep;
use tonic::transport;

use crate::config::{Config, SrsConfig, TtsConfig};
use crate::rpc::{HookRpc, MissionRpc, Srs};
use crate::shutdown::{Shutdown, ShutdownHandle};
use crate::srs::SrsClients;
use crate::stats::Stats;
use tonic_middleware::RequestInterceptorLayer;

pub struct Server {
runtime: Runtime,
Expand All @@ -50,6 +51,7 @@ struct ServerState {
tts_config: TtsConfig,
srs_config: SrsConfig,
srs_transmit: Arc<Mutex<mpsc::Receiver<TransmitRequest>>>,
auth_config: AuthConfig,
}

impl Server {
Expand All @@ -71,6 +73,7 @@ impl Server {
tts_config: config.tts.clone().unwrap_or_default(),
srs_config: config.srs.clone().unwrap_or_default(),
srs_transmit: Arc::new(Mutex::new(rx)),
auth_config: config.auth.clone().unwrap_or_default(),
},
srs_transmit: tx,
shutdown,
Expand Down Expand Up @@ -203,6 +206,7 @@ async fn try_run(
tts_config,
srs_config,
srs_transmit,
auth_config,
} = state;

let mut mission_rpc =
Expand Down Expand Up @@ -242,7 +246,14 @@ async fn try_run(
}
});

let auth_interceptor = AuthInterceptor {
auth_config: auth_config.clone(),
};

log::info!("Authentication enabled: {}", auth_config.enabled);

transport::Server::builder()
.layer(RequestInterceptorLayer::new(auth_interceptor.clone()))
.add_service(AtmosphereServiceServer::new(mission_rpc.clone()))
.add_service(CoalitionServiceServer::new(mission_rpc.clone()))
.add_service(ControllerServiceServer::new(mission_rpc.clone()))
Expand Down

0 comments on commit 36a187d

Please sign in to comment.