Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
400 changes: 400 additions & 0 deletions modules/luci-base/ucode/authplugins.uc

Large diffs are not rendered by default.

82 changes: 81 additions & 1 deletion modules/luci-base/ucode/dispatcher.uc
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { hash, load_catalog, change_catalog, translate, ntranslate, getuid } fro
import { revision as luciversion, branch as luciname } from 'luci.version';
import { default as LuCIRuntime } from 'luci.runtime';
import { urldecode } from 'luci.http';
import { get_challenges, verify } from 'luci.authplugins';

let ubus = connect();
let uci = cursor();
Expand Down Expand Up @@ -520,6 +521,15 @@ function session_setup(user, pass, path) {
closelog();
}

function set_auth_required_plugins(session, plugin_ids) {
ubus.call("session", "set", {
ubus_rpc_session: session.sid,
values: {
pending_auth_plugins: (type(plugin_ids) == 'array') ? plugin_ids : null
}
});
}

function check_authentication(method) {
let m = match(method, /^([[:alpha:]]+):(.+)$/);
let sid;
Expand Down Expand Up @@ -936,6 +946,19 @@ dispatch = function(_http, path) {
pass = http.formvalue('luci_password');
}

let auth_check = get_challenges(http, user ?? 'root');
let auth_fields = null;
let auth_message = null;
let auth_html = null;
let auth_assets = null;

if (auth_check.pending) {
auth_fields = auth_check.fields;
auth_message = auth_check.message;
auth_html = auth_check.html;
auth_assets = auth_check.assets;
}

if (user != null && pass != null)
session = session_setup(user, pass, resolved.ctx.request_path);

Expand All @@ -945,7 +968,15 @@ dispatch = function(_http, path) {
http.status(403, 'Forbidden');
http.header('X-LuCI-Login-Required', 'yes');

let scope = { duser: 'root', fuser: user };
// Show login form with 2FA fields if required
let scope = {
duser: 'root',
fuser: user,
auth_fields: auth_fields,
auth_message: auth_message,
auth_html: auth_html,
auth_assets: auth_assets
};
let theme_sysauth = `themes/${basename(runtime.env.media)}/sysauth`;

if (runtime.is_ucode_template(theme_sysauth) || runtime.is_lua_template(theme_sysauth)) {
Expand All @@ -960,6 +991,55 @@ dispatch = function(_http, path) {
return runtime.render('sysauth', scope);
}

let auth_user = session.data?.username;
if (!auth_user)
auth_user = user;

// Compute required plugin list once for authenticated user and bind it to the temporary session.
auth_check = get_challenges(http, auth_user);
if (auth_check.pending) {
let required_plugin_ids = map(auth_check.challenges, c => c.uuid);
set_auth_required_plugins(session, required_plugin_ids);

// Verify exactly the plugin list stored in this temporary session
let auth_verify = verify(http, auth_user, required_plugin_ids);

if (!auth_verify.success) {
// Additional auth failed or not provided
// Destroy the temporary session to prevent bypass
ubus.call("session", "destroy", { ubus_rpc_session: session.sid });

resolved.ctx.path = [];
http.status(403, 'Forbidden');
http.header('X-LuCI-Login-Required', 'yes');

let scope = {
duser: 'root',
fuser: user,
auth_plugin: length(auth_check.challenges) ? auth_check.challenges[0].name : null,
auth_fields: auth_check.fields,
auth_message: auth_verify.message ?? auth_check.message,
auth_html: auth_check.html,
auth_assets: auth_check.assets
};

let theme_sysauth = `themes/${basename(runtime.env.media)}/sysauth`;

if (runtime.is_ucode_template(theme_sysauth) || runtime.is_lua_template(theme_sysauth)) {
try {
return runtime.render(theme_sysauth, scope);
}
catch (e) {
runtime.env.media_error = `${e}`;
}
}

return runtime.render('sysauth', scope);
}

set_auth_required_plugins(session, null);
}

let cookie_name = (http.getenv('HTTPS') == 'on') ? 'sysauth_https' : 'sysauth_http',
cookie_secure = (http.getenv('HTTPS') == 'on') ? '; secure' : '';

Expand Down
37 changes: 37 additions & 0 deletions modules/luci-base/ucode/template/sysauth.ut
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
</div>
{% endif %}

{% if (auth_message && !fuser): %}
<div class="alert-message">
<p>{{ auth_message }}</p>
</div>
{% endif %}

<div class="cbi-map">
<h2 name="content">{{ _('Authorization Required') }}</h2>
<div class="cbi-map-descr">
Expand All @@ -31,6 +37,37 @@
<input class="cbi-input-text" type="password" name="luci_password" />
</div>
</div>
{% if (auth_fields): %}
{% for (let field in auth_fields): %}
<div class="cbi-value">
<label class="cbi-value-title">{{ _(field.label ?? field.name) }}</label>
<div class="cbi-value-field">
<input class="cbi-input-text"
type="{{ field.type ?? 'text' }}"
name="{{ field.name }}"
{% if (field.placeholder): %}placeholder="{{ field.placeholder }}"{% endif %}
{% if (field.inputmode): %}inputmode="{{ field.inputmode }}"{% endif %}
{% if (field.pattern): %}pattern="{{ field.pattern }}"{% endif %}
{% if (field.maxlength): %}maxlength="{{ field.maxlength }}"{% endif %}
{% if (field.autocomplete): %}autocomplete="{{ field.autocomplete }}"{% endif %}
{% if (field.required): %}required{% endif %}
/>
</div>
</div>
{% endfor %}
{% endif %}
{% if (auth_html): %}
<div class="cbi-value">
{{ auth_html }}
</div>
{% endif %}
{% if (auth_assets): %}
{% for (let asset in auth_assets): %}
{% if (asset.type == 'script'): %}
<script src="{{ asset.src }}"></script>
{% endif %}
{% endfor %}
{% endif %}
</div></div>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@
"description": "Grant access to Plugin management",
"read": {
"file": {
"/usr/share/ucode/luci/*": [ "read" ]
"/usr/share/ucode/luci/*": [ "read" ],
"/www/luci-static/resources/view/plugins": [ "list" ],
"/www/luci-static/resources/view/plugins/*": [ "read" ]
},
"uci": [ "luci_plugins" ]
}
Expand Down
11 changes: 11 additions & 0 deletions plugins/luci-auth-example/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
include $(TOPDIR)/rules.mk

PKG_NAME:=luci-auth-example
PKG_VERSION:=1.0
PKG_RELEASE:=1

PKG_LICENSE:=Apache-2.0

include ../../luci.mk

# call BuildPackage - OpenWrt buildroot signature
164 changes: 164 additions & 0 deletions plugins/luci-auth-example/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# LuCI Authentication Plugin Example

This package demonstrates how to create authentication plugins for LuCI
that integrate with the plugin UI architecture (System > Plugins).

## Architecture

Authentication plugins consist of two components:

### 1. Backend Plugin (ucode)
**Location**: `/usr/share/ucode/luci/plugins/auth/login/<uuid>.uc`

The backend plugin implements the authentication logic. It must:
- Return a plugin object
- Provide a `check(http, user)` method to determine if auth is required
- Provide a `verify(http, user)` method to validate the auth response
- Use a 32-character hexadecimal UUID as the filename

**Example structure**:
```javascript
return {
priority: 10, // Optional: execution order (lower = first)

check: function(http, user) {
// Return { required: true/false, fields: [...], message: '...', html: '...', assets: [...] }
},

verify: function(http, user) {
// Return { success: true/false, message: '...' }
}
};
```

### 2. UI Plugin (JavaScript)
**Location**: `/www/luci-static/resources/view/plugins/<uuid>.js`

The UI plugin provides configuration interface in System > Plugins. It must:
- Extend `baseclass`
- Define `class: 'auth'` and `type: 'login'`
- Use the same UUID as the backend plugin (without .uc extension)
- Implement `addFormOptions(s)` to add configuration fields
- Optionally implement `configSummary(section)` to show current config

**Example structure**:
```javascript
return baseclass.extend({
class: 'auth',
class_i18n: _('Authentication'),
type: 'login',
type_i18n: _('Login'),

id: 'd0ecde1b009d44ff82faa8b0ff219cef',
name: 'My Auth Plugin',
title: _('My Auth Plugin'),
description: _('Description of what this plugin does'),

addFormOptions(s) {
// Add configuration options using form.*
},

configSummary(section) {
// Return summary string to display in plugin list
}
});
```

## Configuration

Plugins are configured through the `luci_plugins` UCI config:

```
config global 'global'
option enabled '1' # Global plugin system
option auth_login_enabled '1' # Auth plugin class

config auth_login 'd0ecde1b009d44ff82faa8b0ff219cef'
option name 'Example Auth Plugin'
option enabled '1'
option priority '10'
option challenge_field 'verification_code'
option help_text 'Enter your code'
option test_code '123456'
```

## Integration with Login Flow

1. User enters username/password
2. If password is correct, `check()` is called on each enabled auth plugin
3. If any plugin returns `required: true`, the login form shows additional fields
and optional raw HTML/JS assets
4. User submits the additional fields
5. `verify()` is called to validate the response
6. If verification succeeds, session is granted
7. If verification fails, user must try again

The dispatcher stores the required plugin UUID list in session state before
verification, then clears it by setting `pending_auth_plugins` to `null` after
successful verification.

Priority is configurable via `luci_plugins.<uuid>.priority` (lower values run first).
If changed at runtime, reload plugin cache or restart services to apply.

## Raw HTML + JS Assets

Plugins may return:

- `html`: raw HTML snippet inserted into the login form
- `assets`: script URLs for challenge UI behavior

Asset security rules:

- URLs must be under `/luci-static/plugins/<plugin-uuid>/`
- Invalid asset URLs are ignored by the framework
- Keep `html` static or generated from trusted values only

## Generating a UUID

Use one of these methods:
```bash
# Linux
cat /proc/sys/kernel/random/uuid | tr -d '-'

# macOS
uuidgen | tr -d '-' | tr '[:upper:]' '[:lower:]'

# Online
# Visit https://www.uuidgenerator.net/ and remove dashes
```

## Plugin Types

Common authentication plugin types:
- **TOTP/OTP**: Time-based one-time passwords (Google Authenticator, etc.)
- **SMS**: SMS verification codes
- **Email**: Email verification codes
- **WebAuthn**: FIDO2/WebAuthn hardware keys
- **Biometric**: Fingerprint, face recognition (mobile apps)
- **Push Notification**: Approve/deny on mobile device
- **Security Questions**: Additional security questions

## Testing

1. Install the plugin package
2. Navigate to System > Plugins
3. Enable "Global plugin system"
4. Enable "Authentication > Login"
5. Enable the specific auth plugin and configure it
6. Log out and try logging in
7. After entering correct password, you should see the auth challenge

## Real Implementation Examples

For production use, integrate with actual authentication systems:

- **TOTP**: Use `oathtool` command or liboath library
- **SMS**: Integrate with SMS gateway API
- **WebAuthn**: Use WebAuthn JavaScript API and verify on server
- **LDAP 2FA**: Query LDAP server for 2FA attributes

## See Also

- LuCI Plugin Architecture: commit 617f364
- HTTP Header Plugins: `plugins/plugins-example/`
- LuCI Dispatcher: `modules/luci-base/ucode/dispatcher.uc`
Loading
Loading