Skip to content

Commit 28c71ea

Browse files
CopilotSteake
andauthored
Implement JWT authentication, RBAC, and audit logging for admin console (#104)
* Initial plan * Implement JWT auth, RBAC, and audit logging for admin console Co-authored-by: Steake <530040+Steake@users.noreply.github.com> * Add integration tests, fix security issues, and add documentation Co-authored-by: Steake <530040+Steake@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Steake <530040+Steake@users.noreply.github.com>
1 parent 24c9c8a commit 28c71ea

9 files changed

Lines changed: 1713 additions & 38 deletions

File tree

crates/bitcell-admin/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ chrono = { version = "0.4", features = ["serde"] }
6666
# Sync primitives
6767
parking_lot = "0.12"
6868

69+
# JWT and authentication
70+
jsonwebtoken = "9.2"
71+
bcrypt = "0.15"
72+
uuid = { version = "1.6", features = ["v4", "serde"] }
73+
6974
# BitCell dependencies
7075
bitcell-node = { path = "../bitcell-node" }
7176
bitcell-consensus = { path = "../bitcell-consensus" }
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
//! Authentication API endpoints
2+
3+
use axum::{
4+
extract::State,
5+
http::StatusCode,
6+
Json,
7+
};
8+
use serde::{Deserialize, Serialize};
9+
use std::sync::Arc;
10+
11+
use crate::{AppState, auth::{AuthUser, LoginRequest, RefreshRequest, Role}};
12+
13+
/// Login endpoint
14+
pub async fn login(
15+
State(state): State<Arc<AppState>>,
16+
Json(req): Json<LoginRequest>,
17+
) -> Result<Json<crate::auth::AuthResponse>, crate::auth::AuthError> {
18+
let result = state.auth.login(req.clone());
19+
20+
// Log authentication attempt
21+
match &result {
22+
Ok(response) => {
23+
state.audit.log_success(
24+
response.user.id.clone(),
25+
response.user.username.clone(),
26+
"login".to_string(),
27+
"auth".to_string(),
28+
None,
29+
);
30+
}
31+
Err(_) => {
32+
state.audit.log_failure(
33+
"unknown".to_string(),
34+
req.username.clone(),
35+
"login".to_string(),
36+
"auth".to_string(),
37+
"Invalid credentials".to_string(),
38+
);
39+
}
40+
}
41+
42+
result.map(Json)
43+
}
44+
45+
/// Refresh token endpoint
46+
pub async fn refresh(
47+
State(state): State<Arc<AppState>>,
48+
Json(req): Json<RefreshRequest>,
49+
) -> Result<Json<crate::auth::AuthResponse>, crate::auth::AuthError> {
50+
let result = state.auth.refresh(req);
51+
52+
// Log token refresh
53+
if let Ok(response) = &result {
54+
state.audit.log_success(
55+
response.user.id.clone(),
56+
response.user.username.clone(),
57+
"refresh_token".to_string(),
58+
"auth".to_string(),
59+
None,
60+
);
61+
}
62+
63+
result.map(Json)
64+
}
65+
66+
/// Logout endpoint (revokes token)
67+
pub async fn logout(
68+
user: AuthUser,
69+
State(state): State<Arc<AppState>>,
70+
req: axum::extract::Request,
71+
) -> Result<Json<LogoutResponse>, StatusCode> {
72+
// Extract token from header
73+
if let Some(auth_header) = req.headers().get(axum::http::header::AUTHORIZATION) {
74+
if let Ok(auth_str) = auth_header.to_str() {
75+
if let Some(token) = auth_str.strip_prefix("Bearer ") {
76+
state.auth.revoke_token(token.to_string());
77+
78+
state.audit.log_success(
79+
user.claims.sub.clone(),
80+
user.claims.username.clone(),
81+
"logout".to_string(),
82+
"auth".to_string(),
83+
None,
84+
);
85+
86+
return Ok(Json(LogoutResponse {
87+
message: "Logged out successfully".to_string(),
88+
}));
89+
}
90+
}
91+
}
92+
93+
Err(StatusCode::BAD_REQUEST)
94+
}
95+
96+
#[derive(Serialize)]
97+
pub struct LogoutResponse {
98+
pub message: String,
99+
}
100+
101+
/// Create user endpoint (admin only)
102+
#[derive(Deserialize)]
103+
pub struct CreateUserRequest {
104+
pub username: String,
105+
pub password: String,
106+
pub role: Role,
107+
}
108+
109+
#[derive(Serialize)]
110+
pub struct CreateUserResponse {
111+
pub id: String,
112+
pub username: String,
113+
pub role: Role,
114+
}
115+
116+
pub async fn create_user(
117+
user: AuthUser,
118+
State(state): State<Arc<AppState>>,
119+
Json(req): Json<CreateUserRequest>,
120+
) -> Result<Json<CreateUserResponse>, crate::auth::AuthError> {
121+
// Only admin can create users
122+
if user.claims.role != Role::Admin {
123+
state.audit.log_failure(
124+
user.claims.sub.clone(),
125+
user.claims.username.clone(),
126+
"create_user".to_string(),
127+
req.username.clone(),
128+
"Insufficient permissions".to_string(),
129+
);
130+
return Err(crate::auth::AuthError::InsufficientPermissions);
131+
}
132+
133+
let result = state.auth.add_user(req.username.clone(), req.password, req.role);
134+
135+
match &result {
136+
Ok(new_user) => {
137+
state.audit.log_success(
138+
user.claims.sub.clone(),
139+
user.claims.username.clone(),
140+
"create_user".to_string(),
141+
new_user.username.clone(),
142+
Some(format!("Created user with role: {:?}", new_user.role)),
143+
);
144+
145+
Ok(Json(CreateUserResponse {
146+
id: new_user.id.clone(),
147+
username: new_user.username.clone(),
148+
role: new_user.role,
149+
}))
150+
}
151+
Err(e) => {
152+
state.audit.log_failure(
153+
user.claims.sub.clone(),
154+
user.claims.username.clone(),
155+
"create_user".to_string(),
156+
req.username,
157+
e.to_string(),
158+
);
159+
Err(e.clone())
160+
}
161+
}
162+
}
163+
164+
/// Get audit logs endpoint (admin and operator can view)
165+
#[derive(Deserialize)]
166+
pub struct AuditLogsQuery {
167+
#[serde(default = "default_limit")]
168+
pub limit: usize,
169+
}
170+
171+
fn default_limit() -> usize {
172+
100
173+
}
174+
175+
#[derive(Serialize)]
176+
pub struct AuditLogsResponse {
177+
pub logs: Vec<crate::audit::AuditLogEntry>,
178+
pub total: usize,
179+
}
180+
181+
pub async fn get_audit_logs(
182+
user: AuthUser,
183+
State(state): State<Arc<AppState>>,
184+
axum::extract::Query(query): axum::extract::Query<AuditLogsQuery>,
185+
) -> Result<Json<AuditLogsResponse>, StatusCode> {
186+
// Only admin and operator can view audit logs
187+
if !matches!(user.claims.role, Role::Admin | Role::Operator) {
188+
return Err(StatusCode::FORBIDDEN);
189+
}
190+
191+
let all_logs = state.audit.get_logs();
192+
let total = all_logs.len();
193+
let logs = state.audit.get_recent_logs(query.limit);
194+
195+
state.audit.log_success(
196+
user.claims.sub.clone(),
197+
user.claims.username.clone(),
198+
"view_audit_logs".to_string(),
199+
"audit".to_string(),
200+
Some(format!("Retrieved {} logs", logs.len())),
201+
);
202+
203+
Ok(Json(AuditLogsResponse { logs, total }))
204+
}

crates/bitcell-admin/src/api/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub mod test;
88
pub mod setup;
99
pub mod blocks;
1010
pub mod wallet;
11+
pub mod auth;
1112

1213
use std::collections::HashMap;
1314
use std::sync::RwLock;

0 commit comments

Comments
 (0)