[pve-devel] [PATCH proxmox_login_manager 1/1] fix #4281: login_manager: UI changes for enabling login with Open ID
Alexander Abraham
a.abraham at proxmox.com
Tue Apr 29 17:07:56 CEST 2025
This commit adds an authorization flow for logging a user in
with Open ID in the Flutter App's UI. An authorization URL
is obtained and opened in a webview. From there, the user
types in their credentials and the login is processed and
the user is logged in to the PVE app.
Signed-off-by: Alexander Abraham <a.abraham at proxmox.com>
---
lib/proxmox_login_form.dart | 222 +++++++++++++++++++++++++++++++-----
1 file changed, 192 insertions(+), 30 deletions(-)
diff --git a/lib/proxmox_login_form.dart b/lib/proxmox_login_form.dart
index 735bd42..7dfba9f 100644
--- a/lib/proxmox_login_form.dart
+++ b/lib/proxmox_login_form.dart
@@ -1,6 +1,7 @@
import 'dart:io';
import 'dart:async';
-
+import 'dart:convert';
+import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:proxmox_dart_api_client/proxmox_dart_api_client.dart'
@@ -12,6 +13,12 @@ import 'package:proxmox_login_manager/proxmox_login_model.dart';
import 'package:proxmox_login_manager/proxmox_tfa_form.dart';
import 'package:proxmox_login_manager/extension.dart';
import 'package:proxmox_login_manager/proxmox_password_store.dart';
+import 'package:flutter_inappwebview/flutter_inappwebview.dart';
+
+typedef AuthCallBack = Future<void> Function(
+ InAppWebViewController controller,
+ NavigationAction navAction
+);
class ProxmoxProgressModel {
int inProgress = 0;
@@ -42,9 +49,12 @@ class ProxmoxLoginForm extends StatefulWidget {
final Function? onSavePasswordChanged;
final bool? canSavePassword;
final bool? passwordSaved;
+ final bool isOIDC;
+ final bool showOIDCAuth;
const ProxmoxLoginForm({
super.key,
+ required this.isOIDC,
required this.originController,
required this.usernameController,
required this.passwordController,
@@ -57,6 +67,7 @@ class ProxmoxLoginForm extends StatefulWidget {
this.onSavePasswordChanged,
this.canSavePassword,
this.passwordSaved,
+ required this.showOIDCAuth
});
@override
@@ -97,7 +108,8 @@ class _ProxmoxLoginFormState extends State<ProxmoxLoginForm> {
controller: widget.originController,
enabled: false,
),
- TextFormField(
+
+ if (widget.isOIDC == false) TextFormField(
decoration: const InputDecoration(
icon: Icon(Icons.person),
labelText: 'Username',
@@ -123,11 +135,11 @@ class _ProxmoxLoginFormState extends State<ProxmoxLoginForm> {
))
.toList(),
onChanged: widget.onDomainChanged,
- selectedItemBuilder: (context) =>
- widget.accessDomains!.map((e) => Text(e!.realm)).toList(),
+ selectedItemBuilder: (context) => widget.accessDomains!.map((e) =>
+ Text(e!.realm)).toList(),
value: widget.selectedDomain,
),
- Stack(
+ if (widget.isOIDC == false) Stack(
children: [
TextFormField(
decoration: const InputDecoration(
@@ -150,14 +162,18 @@ class _ProxmoxLoginFormState extends State<ProxmoxLoginForm> {
Align(
alignment: Alignment.bottomRight,
child: IconButton(
- constraints: BoxConstraints.tight(const Size(58, 58)),
+ constraints: BoxConstraints.tight(const Size(58,
+ 58)),
iconSize: 24,
tooltip: _obscure ? "Show password" : "Hide password",
icon:
- Icon(_obscure ? Icons.visibility : Icons.visibility_off),
- onPressed: () => setState(() {
- _obscure = !_obscure;
- }),
+ Icon(_obscure ? Icons.visibility : Icons.visibility_off),
+ onPressed: () => setState(
+ () {
+
+ _obscure = !_obscure;
+ }
+ ),
),
)
],
@@ -169,12 +185,12 @@ class _ProxmoxLoginFormState extends State<ProxmoxLoginForm> {
onChanged: (value) {
if (widget.onSavePasswordChanged != null) {
widget.onSavePasswordChanged!(value!);
- }
- setState(() {
- _savePwCheckbox = value!;
- });
- },
- )
+ }
+ setState(() {
+ _savePwCheckbox = value!;
+ });
+ },
+ )
],
),
);
@@ -215,6 +231,11 @@ class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
bool _submittButtonEnabled = true;
bool _canSavePassword = false;
bool _savePasswordCB = false;
+ bool isOIDC = false;
+ bool showOIDCAuth = false;
+ late String oidcUserName;
+ late String oidcTicket;
+ late String oidcCRSF;
@override
void initState() {
@@ -327,6 +348,7 @@ class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
child: FutureBuilder<List<PveAccessDomainModel?>?>(
future: _accessDomains,
builder: (context, snapshot) {
+
return Form(
key: _formKey,
onChanged: () {
@@ -338,7 +360,7 @@ class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
- Expanded(
+ Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@@ -350,6 +372,7 @@ class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
),
),
ProxmoxLoginForm(
+ showOIDCAuth: showOIDCAuth,
originController: _originController,
originValidator: (value) {
if (value == null || value.isEmpty) {
@@ -364,6 +387,7 @@ class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
return 'Invalid URI: $e';
}
},
+ isOIDC: isOIDC,
usernameController: _usernameController,
passwordController: _passwordController,
accessDomains: snapshot.data,
@@ -376,6 +400,16 @@ class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
onDomainChanged: (value) {
setState(() {
_selectedDomain = value;
+ if (_selectedDomain!.comment.toString() == "null"){
+ setState((){
+ isOIDC = true;
+ _submittButtonEnabled = true;
+ _canSavePassword = false;
+ });
+ }
+ else {
+ setState(() => isOIDC = false);
+ }
});
},
onOriginSubmitted: () {
@@ -392,6 +426,7 @@ class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
},
onPasswordSubmitted: _submittButtonEnabled
? () {
+
final isValid =
_formKey.currentState!.validate();
setState(() {
@@ -411,6 +446,46 @@ class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
child: TextButton(
onPressed: _submittButtonEnabled
? () {
+ if (isOIDC) {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) =>
+ OIDCAuthWidget(
+ realm:_selectedDomain!.realm,
+ redirectUrl: _originController.text,
+ host:_originController.text,
+ authHandler: (controller,navAction) async {
+ Map<String,
+ String> creds = parseUrl(
+ navAction
+ .request
+ .url
+ .toString()
+ );
+
+ String pveAuth = await fetchOIDCCredentials(
+ creds["code"]!,
+ creds["state"]!,
+ creds["host"]!
+ );
+ Map<String,dynamic> serverCreds = jsonDecode(
+ pveAuth
+ )["data"]!;
+ String username = serverCreds["username"]!
+ .split("@")[0];
+ String ticket = serverCreds["ticket"]!;
+ setState((){
+ _usernameController.text = username;
+ _passwordController.text = ticket;
+ });
+ _onLoginButtonPressed();
+ }
+ )
+ )
+ );
+ }
+ else {
final isValid = _formKey
.currentState!
.validate();
@@ -428,17 +503,19 @@ class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
});
}
}
- }
+ }}
: null,
child: const Text('Continue'),
),
),
- ),
- ),
+ ),
+ ),
],
),
);
- }),
+
+ }
+ ),
),
),
),
@@ -460,19 +537,15 @@ class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
});
try {
- final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
- //cleaned form fields
final origin = normalizeUrl(_originController.text.trim());
- final username = _usernameController.text.trim();
final String enteredPassword = _passwordController.text.trim();
final String? savedPassword = widget.password;
-
+ final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
+ final username = _usernameController.text.trim();
final password = ticket.isNotEmpty ? ticket : enteredPassword;
final realm = _selectedDomain?.realm ?? mRealm;
-
var client = await proxclient.authenticate(
- '$username@$realm', password, origin, settings.sslValidation!);
-
+ '$username@$realm', password, origin, settings.sslValidation!);
if (client.credentials.tfa != null &&
client.credentials.tfa!.kinds().isNotEmpty) {
if (!mounted) return;
@@ -566,7 +639,6 @@ class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
Navigator.of(context).pop(client);
}
} on proxclient.ProxmoxApiException catch (e) {
- print(e);
if (!mounted) return;
if (e.message.contains('No ticket')) {
showDialog(
@@ -703,8 +775,12 @@ class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
setState(() {
_progressModel.inProgress -= 1;
+ if(response![0]!.comment == null){
+ isOIDC = true;
+ }
+ else {}
_selectedDomain = selection;
- });
+ });
return response;
}
@@ -847,3 +923,89 @@ Uri normalizeUrl(String urlText) {
return Uri.https(urlText, '');
}
+
+
+class OIDCAuthWidget extends StatefulWidget {
+ final String host;
+ final String realm;
+ final String redirectUrl;
+ final AuthCallBack authHandler;
+
+ OIDCAuthWidget(
+ {
+ required this.host,
+ required this.realm,
+ required this.authHandler,
+ required this.redirectUrl
+ }
+ );
+
+ OIDCAuthWidgetState createState() => OIDCAuthWidgetState();
+}
+class OIDCAuthWidgetState extends State<OIDCAuthWidget>{
+ final GlobalKey webViewKey = GlobalKey();
+ late InAppWebViewController webController;
+ late Future<String> authUrl;
+ InAppWebViewSettings settings = InAppWebViewSettings(
+ useShouldOverrideUrlLoading: true
+ );
+
+ void initState(){
+ super.initState();
+ authUrl = fetchOIDCAuthUrl(
+ widget.realm,
+ widget.host,
+ widget.redirectUrl
+ );
+ }
+
+ Widget build(BuildContext context){
+ return FutureBuilder<String>(
+ future: this.authUrl,
+ builder: (context, snapshot) {
+ if (snapshot.connectionState == ConnectionState.done){
+ String data = snapshot.data!;
+ if (data == ""){
+ return Scaffold(
+ body: Center(
+ child: Text(
+ "Data could not be loaded."
+ )
+ )
+ );
+ }
+ else {
+ String fetchedUrl = snapshot.data!;
+ return Scaffold(body:InAppWebView(
+ key: webViewKey,
+ initialUrlRequest: URLRequest(
+ url: WebUri(
+ fetchedUrl
+ )
+ ),
+ initialSettings: settings,
+ onWebViewCreated: (controller){
+ webController = controller;
+ },
+ shouldOverrideUrlLoading: (controller, navAction) async{
+ await widget.authHandler(controller, navAction);
+ Navigator.pop(context);
+ }
+ ));
+ }
+ }
+ return Scaffold(
+ body: Center(
+ child: Text(
+ "Loading..",
+ style: TextStyle(
+ fontSize: (MediaQuery.of(context).size.width/100.0)*5,
+ fontWeight: FontWeight.bold,
+ )
+ )
+ )
+ );
+ }
+ );
+ }
+}
--
2.39.5
More information about the pve-devel
mailing list